From eda66eeb6ce60039c7370924580fb68a2563c24a Mon Sep 17 00:00:00 2001 From: Slava Date: Fri, 15 May 2026 13:45:42 +0300 Subject: [PATCH] feat: migrate prototype to canonical structure - Move app.py to src/api/main.py (STATIC_DIR from env) - Move static/ to src/web/ - Update Dockerfile and docker-compose.yml - Add download_srtm.sh script - Clean up debug/deploy scripts Refs: multi-agent F0-3 --- Dockerfile | 2 + docker-compose.yml | 7 +- scripts/download_srtm.sh | 56 + src/{api/.gitkeep => __init__.py} | 0 src/{web/.gitkeep => api/__init__.py} | 0 src/api/main.py | 1255 +++++++++++ src/api/requirements.txt | 5 + src/web/app.css | 771 +++++++ src/web/app.js | 2790 +++++++++++++++++++++++++ src/web/index.html | 363 ++++ src/web/style-dark.json | 136 ++ src/web/style.json | 136 ++ 12 files changed, 5520 insertions(+), 1 deletion(-) create mode 100755 scripts/download_srtm.sh rename src/{api/.gitkeep => __init__.py} (100%) rename src/{web/.gitkeep => api/__init__.py} (100%) create mode 100644 src/api/main.py create mode 100644 src/api/requirements.txt create mode 100644 src/web/app.css create mode 100644 src/web/app.js create mode 100644 src/web/index.html create mode 100644 src/web/style-dark.json create mode 100644 src/web/style.json 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/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/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 += `
+
+
+
+
`; + } + container.innerHTML = html; +} + +// ─── Deactivate All Modes ────────────────────────────────────────── + +function deactivateAllModes() { + // Deactivate all input modes but preserve route/scenic/link data on map + if (routeMode) { routeMode = false; document.getElementById('tb-route').classList.remove('active'); closeSheet('sheet-route'); /* NOT clearRoute - keep line on map */ } + if (rulerMode) toggleRuler(); + if (markerMode) toggleMarkerMode(); + if (typeof reconMode !== 'undefined' && reconMode) toggleReconMode(); + if (typeof linkMode !== 'undefined' && linkMode) toggleLinkMode(); + if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); + if (window._map) window._map.getCanvas().style.cursor = ''; + updateMapModeClass(); +} + +function updateMapModeClass() { + const has = routeMode || rulerMode || markerMode || reconMode || linkMode || scenicMode; + document.body.classList.toggle('has-map-mode', !!has); +} + +// ─── Компас ──────────────────────────────────────────────────────── +let compassLocked = false; + +function toggleCompass() { + const map = window._map; + if (!map) return; + const btn = document.getElementById('btn-compass'); + compassLocked = !compassLocked; + if (compassLocked) { + map.rotateTo(0, { duration: 300 }); + map.dragRotate.disable(); + map.touchZoomRotate.disableRotation(); + btn.classList.add('active'); + } else { + map.dragRotate.enable(); + map.touchZoomRotate.enableRotation(); + btn.classList.remove('active'); + } +} + +// ─── Геолокация ──────────────────────────────────────────────────── +let locationMarker = null; + +function locateMe() { + if (!navigator.geolocation) { + alert('Геолокация недоступна в этом браузере'); + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => { + const { longitude, latitude } = pos.coords; + const map = window._map; + userLat = latitude; + userLon = longitude; + map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 }); + if (locationMarker) { + locationMarker.setLngLat([longitude, latitude]); + } else { + const el = document.createElement('div'); + el.className = 'my-location-marker'; + el.innerHTML = '
'; + locationMarker = new maplibregl.Marker({ element: el, anchor: 'center' }) + .setLngLat([longitude, latitude]) + .addTo(map); + } + // If in auto theme mode, recalculate with real coordinates + if (themeMode === 'auto') applyAutoTheme(); + }, + (err) => { + alert('Не удалось определить местоположение: ' + err.message); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); +} + +// ─── Layer visibility state ──────────────────────────────────────── +const layerState = { tracks: true, paths: true, poi: true, basemap: true }; +const layerGroups = { + tracks: ['trails-track', 'trails-asphalt'], + paths: ['trails-path-bridleway'], + poi: ['poi-circles', 'poi-labels'], + basemap: ['osm-base'], +}; + +function toggleLayer(group) { + layerState[group] = !layerState[group]; + const btn = document.getElementById('btn-' + group); + btn.classList.toggle('active', layerState[group]); + const visibility = layerState[group] ? 'visible' : 'none'; + layerGroups[group].forEach(id => { + if (window._map && window._map.getLayer(id)) { + window._map.setLayoutProperty(id, 'visibility', visibility); + } + }); +} + +// ─── Роутинг - состояние ─────────────────────────────────────────── +const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; +let routeMode = false; +let routeWaypoints = []; +let routeResults = []; +let activeRouteIdx = 0; +let waypointMarkers = []; +let addingWaypoint = false; +let buildDebounceTimer = null; + +function getBasePath() { + return window.location.pathname.replace(/\/[^/]*$/, '') || ''; +} + +// ─── Режим маршрута ──────────────────────────────────────────────── +function toggleRouteMode() { + const btn = document.getElementById('tb-route'); + + if (routeMode) { + // If sheet is open - close sheet but stay in mode + const sheet = document.getElementById('sheet-route'); + if (sheet && sheet.classList.contains('open')) { + closeSheet('sheet-route'); + return; + } + // Sheet is closed - exit mode and clear route + routeMode = false; + btn.classList.remove('active'); + clearRoute(); + window._map.getCanvas().style.cursor = ''; + } else { + // Enter route mode - show onboarding mini-bar instead of full sheet + deactivateAllModes(); + routeMode = true; + btn.classList.add('active'); + clearRoute(); + window._map.getCanvas().style.cursor = 'crosshair'; + showRouteOnboardingMini(); + } + updateMapModeClass(); +} + +function clearRoute() { + hideMiniRouteSheet(); + waypointMarkers.forEach(m => m.remove()); + waypointMarkers = []; + routeWaypoints = []; + routeResults = []; + activeRouteIdx = 0; + addingWaypoint = false; + const map = window._map; + if (map) { + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + } + document.getElementById('route-status').textContent = 'Тапни точку старта на карте'; + document.getElementById('route-cards').innerHTML = ''; + document.getElementById('waypoints-list').innerHTML = ''; + if (routeMode && map) map.getCanvas().style.cursor = 'crosshair'; + hideMiniOnboard(); +} + +function resetRouteFromSheet() { + clearRoute(); + closeSheet('sheet-route'); + if (routeMode) showRouteOnboardingMini(); +} +window.resetRouteFromSheet = resetRouteFromSheet; + +function addWaypointMode() { + if (routeWaypoints.length >= 10) return; + if (!routeMode) { + routeMode = true; + document.getElementById('tb-route').classList.add('active'); + updateMapModeClass(); + } + addingWaypoint = true; + window._map.getCanvas().style.cursor = 'crosshair'; + // Hide main sheet so the map is visible for tapping + closeSheet('sheet-route'); + // Show onboarding mini-bar for "add waypoint" mode + _showMiniOnboardWaypoint(); +} + +function _showMiniOnboardWaypoint() { + // Reuse mini-onboard UI with "add waypoint" hint + const idx = routeWaypoints.length; // next waypoint index + const label = String(idx); + const color = '#0066ff'; + const hint = 'Тапни на карте — добавить точку'; + + document.getElementById('mini-onboard').style.display = 'flex'; + document.getElementById('mini-dot').style.display = 'none'; + document.getElementById('mini-label').style.display = 'none'; + document.getElementById('mini-stats').style.display = 'none'; + document.getElementById('mini-wheel').style.display = 'none'; + const arrows = document.querySelector('.mini-route-arrows'); + if (arrows) arrows.style.display = 'none'; + const addBtn = document.getElementById('mini-add-btn'); + if (addBtn) addBtn.style.display = 'none'; + + document.getElementById('mini-onboard-pin').innerHTML = waypointPinSvg(label, color); + document.getElementById('mini-onboard-hint').textContent = hint; + + document.getElementById('sheet-route-mini').classList.add('visible'); + const ctrl = document.getElementById('map-controls-r'); + if (ctrl) ctrl.style.bottom = '148px'; + + // Search button + const searchBtn = document.getElementById('mini-onboard-search-btn'); + searchBtn.onclick = () => toggleMiniOnboardSearch('waypoint'); + + // Показать кнопку отмены + const cancelBtn = document.getElementById('mini-onboard-cancel-btn'); + if (cancelBtn) { cancelBtn.style.display = 'inline-flex'; cancelBtn.onclick = cancelAddWaypoint; } +} + +// ─── Маркеры точек ───────────────────────────────────────────────── +function createWaypointMarkerEl(index, total) { + const el = document.createElement('div'); + el.className = 'route-waypoint-marker'; + el.style.animation = 'none'; + + let bg, label; + if (index === 0) { + bg = '#2EA043'; label = 'S'; + } else if (index === total - 1) { + bg = '#FF3B1F'; label = 'F'; + } else { + bg = '#0066ff'; label = String(index); + } + + if (label === 'F') { + const uid = Math.random().toString(36).slice(2); + el.innerHTML = ` + + + + + + + + + + F + `; + } else { + el.innerHTML = ` + + ${label} + `; + } + + return el; +} + +function rebuildWaypointMarkers() { + waypointMarkers.forEach(m => m.remove()); + waypointMarkers = []; + const map = window._map; + routeWaypoints.forEach((wp, i) => { + const el = createWaypointMarkerEl(i, routeWaypoints.length); + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', draggable: true }) + .setLngLat([wp.lon, wp.lat]) + .addTo(map); + (function(idx) { + marker.on('dragend', () => { + const lngLat = marker.getLngLat(); + routeWaypoints[idx] = { lon: lngLat.lng, lat: lngLat.lat }; + renderWaypointsList(); + debounceBuildRoute(); + }); + })(i); + waypointMarkers.push(marker); + }); +} + +// ─── Reverse Geocoding ─────────────────────────────────────────── +const geocodeCache = {}; + +async function reverseGeocode(lat, lon) { + const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; + if (geocodeCache[key]) return geocodeCache[key]; + try { + const resp = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&accept-language=ru`, + { headers: { 'Accept-Language': 'ru' } } + ); + const data = await resp.json(); + const a = data.address || {}; + const name = a.village || a.hamlet || a.town || a.city || a.suburb || a.road || a.county || a.state || `${lat.toFixed(3)}, ${lon.toFixed(3)}`; + geocodeCache[key] = name; + return name; + } catch(e) { + return `${lat.toFixed(3)}, ${lon.toFixed(3)}`; + } +} + +function waypointPinSvg(label, color) { + const fs = label.length > 1 ? '7' : '9'; + + // Finish flag — checkered pattern + if (label === 'F') { + const uid = Math.random().toString(36).slice(2); + return ` + + + + + + + + + + + + + ${label} + `; + } + + return ` + + ${label} + `; +} + +function haversineM(a, b) { + const R = 6371000; + const dLat = (b.lat - a.lat) * Math.PI / 180; + const dLon = (b.lon - a.lon) * Math.PI / 180; + const s = Math.sin(dLat/2)**2 + Math.cos(a.lat*Math.PI/180) * Math.cos(b.lat*Math.PI/180) * Math.sin(dLon/2)**2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); +} + +function formatSegmentDist(m) { + if (m < 1000) return Math.round(m) + ' м'; + return (m / 1000).toFixed(1).replace('.', ',') + ' км'; +} + +// Returns array of route-distance segments (meters) for each waypoint. +// segDists[0] = 0, segDists[i] = distance along route geometry from wp[i-1] to wp[i]. +// First waypoint snaps to geometry[0], last to geometry[n-1] — guarantees sum == full route length. +function getRouteSegmentDistances() { + const route = routeResults[activeRouteIdx]; + if (!route || !route.geometry || !route.geometry.coordinates) return null; + + const coords = route.geometry.coordinates; // [[lon, lat], ...] + const n = coords.length; + if (n < 2 || routeWaypoints.length < 2) return null; + + // Convert geometry coords to {lat, lon} for haversineM + const geoPts = coords.map(([lon, lat]) => ({ lat, lon })); + + // Snap each waypoint to nearest geometry index + const snapIdx = routeWaypoints.map(wp => { + let bestIdx = 0, bestDist = Infinity; + for (let j = 0; j < n; j++) { + const d = haversineM(wp, geoPts[j]); + if (d < bestDist) { bestDist = d; bestIdx = j; } + } + return bestIdx; + }); + + // Force first waypoint → geometry start, last waypoint → geometry end + // This ensures segments sum exactly to the full route geometry length + snapIdx[0] = 0; + snapIdx[snapIdx.length - 1] = n - 1; + + // For each segment i→i+1, sum haversine along geometry points + const segDists = [0]; + for (let i = 1; i < routeWaypoints.length; i++) { + const from = snapIdx[i - 1]; + const to = snapIdx[i]; + if (from === to) { + segDists.push(haversineM(routeWaypoints[i - 1], routeWaypoints[i])); + continue; + } + const step = to > from ? 1 : -1; + let dist = 0; + for (let j = from; j !== to; j += step) { + dist += haversineM(geoPts[j], geoPts[j + step]); + } + segDists.push(dist); + } + + // Scale so sum of segments == route.distance_m exactly + const rawTotal = segDists.slice(1).reduce((a, b) => a + b, 0); + if (rawTotal > 0 && route.distance_m > 0) { + const scale = route.distance_m / rawTotal; + for (let i = 1; i < segDists.length; i++) segDists[i] = Math.round(segDists[i] * scale); + } + + return segDists; +} + +async function renderWaypointsList() { + const list = document.getElementById('waypoints-list'); + + // ── Onboarding: no waypoints yet ────────────────────────────── + if (!routeWaypoints.length) { + list.innerHTML = ` +
+
+
${waypointPinSvg('S', '#2EA043')}
+
+ +
+
+
+
или тапни на карте
+
`; + _initOnboardSearch('start'); + _initWaypointDragHandles(list); + return; + } + + // ── Onboarding: only start added, need finish ────────────────── + // (handled below after normal list render) + + const gripSvg = ``; + + const segDists = (routeResults.length > 0 && activeRouteIdx >= 0) + ? getRouteSegmentDistances() + : null; + + let html = routeWaypoints.map((wp, i) => { + const isStart = i === 0; + const isEnd = i === routeWaypoints.length - 1; + const label = isStart ? 'S' : isEnd ? 'F' : String(i); + const color = isStart ? '#2EA043' : isEnd ? '#FF3B1F' : '#0066ff'; + const coordText = `${wp.lat.toFixed(3)}, ${wp.lon.toFixed(3)}`; + const distStr = i > 0 && segDists ? formatSegmentDist(segDists[i]) : + i > 0 ? formatSegmentDist(haversineM(routeWaypoints[i-1], wp)) : ''; + return `
+
${waypointPinSvg(label, color)}
+
+ ${coordText} + ${distStr ? `${distStr}` : ''} +
+ +
${gripSvg}
+ +
+ `; + }).join(''); + + // Кнопка «Добавить точку» в стиле wl-item + if (routeWaypoints.length < 10) { + html += `
+
${waypointPinSvg('+', 'var(--text3)')}
+ Добавить точку +
`; + } + + // Onboarding finish field: only start added, no finish yet + if (routeWaypoints.length === 1) { + html += ` +
+
${waypointPinSvg('F', '#FF3B1F')}
+
+ +
+
+
+
или тапни на карте
`; + } + + list.innerHTML = html; + + // Init finish onboard search if only 1 waypoint + if (routeWaypoints.length === 1) { + _initOnboardSearch('finish'); + } + + // Async geocode + routeWaypoints.forEach(async (wp, i) => { + const name = await reverseGeocode(wp.lat, wp.lon); + const el = document.getElementById(`wl-label-${i}`); + if (el) el.textContent = name; + }); + + // Touch drag-and-drop (mobile only) + _initWaypointDragHandles(list); +} + +// ─── Onboard search helpers ──────────────────────────────────────── +function _initOnboardSearch(type) { + const input = document.getElementById(`wl-onboard-input-${type}`); + const resultsEl = document.getElementById(`wl-onboard-results-${type}`); + if (!input) return; + + let timeout = null; + input.addEventListener('input', () => { + clearTimeout(timeout); + const q = input.value.trim(); + if (q.length < 2) { resultsEl.innerHTML = ''; return; } + timeout = setTimeout(() => _doOnboardSearch(type, q, resultsEl), 400); + }); + + // Autofocus only for start field (finish field appears inline) + if (type === 'start') { + setTimeout(() => input.focus(), 100); + } +} + +async function _doOnboardSearch(type, query, resultsEl) { + resultsEl.innerHTML = '
Поиск...
'; + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&countrycodes=ru&accept-language=ru`; + const resp = await fetch(url); + const data = await resp.json(); + if (!data.length) { + resultsEl.innerHTML = '
Ничего не найдено
'; + return; + } + resultsEl.innerHTML = data.map(item => { + const parts = (item.display_name || '').split(', '); + const name = parts[0]; + const sub = parts.slice(1, 3).join(', '); + return `
+
${name}
+ ${sub ? `
${sub}
` : ''} +
`; + }).join(''); + } catch(e) { + resultsEl.innerHTML = '
Ошибка
'; + } +} + +function _selectOnboardResult(type, lat, lon, name) { + const wp = { lat: parseFloat(lat), lon: parseFloat(lon) }; + if (type === 'start') { + routeWaypoints.unshift(wp); + } else { + routeWaypoints.push(wp); + } + rebuildWaypointMarkers(); + renderWaypointsList(); + window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 12, duration: 600 }); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + updateMiniRouteCard(); +} + +function _initWaypointDragHandles(list) { + let dragIdx = -1; + let startY = 0; + let dragging = false; + let lastOverEl = null; + let lastOverPos = null; + + function getItemEls() { + return Array.from(list.querySelectorAll('.wl-item[data-idx]')); + } + + function clearHighlights() { + getItemEls().forEach(el => { + el.classList.remove('drag-over-top', 'drag-over-bottom', 'dragging'); + }); + } + + function getDropTarget(clientY) { + const items = getItemEls(); + for (const el of items) { + const idx = parseInt(el.dataset.idx, 10); + if (idx === dragIdx) continue; + const rect = el.getBoundingClientRect(); + if (clientY >= rect.top && clientY <= rect.bottom) { + const mid = rect.top + rect.height / 2; + return { el, idx, pos: clientY < mid ? 'top' : 'bottom' }; + } + } + return null; + } + + function startDrag(clientY, idx) { + dragIdx = idx; + startY = clientY; + dragging = false; + lastOverEl = null; + lastOverPos = null; + const dragEl = document.getElementById(`wl-item-${idx}`); + if (dragEl) dragEl.classList.add('dragging'); + } + + function moveDrag(clientY) { + if (dragIdx < 0) return; + const dy = Math.abs(clientY - startY); + if (dy > 5) dragging = true; + if (!dragging) return; + clearHighlights(); + const target = getDropTarget(clientY); + if (target) { + lastOverEl = target.el; + lastOverPos = target.pos; + target.el.classList.add(target.pos === 'top' ? 'drag-over-top' : 'drag-over-bottom'); + } else { + lastOverEl = null; + lastOverPos = null; + } + } + + function endDrag(finalClientY) { + if (dragIdx < 0) return; + clearHighlights(); + const dy = Math.abs(finalClientY - startY); + + if (dragging && dy > 30 && lastOverEl !== null) { + const dropIdx = parseInt(lastOverEl.dataset.idx, 10); + let insertAt = lastOverPos === 'top' ? dropIdx : dropIdx + 1; + const moved = routeWaypoints.splice(dragIdx, 1)[0]; + if (insertAt > dragIdx) insertAt--; + routeWaypoints.splice(insertAt, 0, moved); + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + updateMiniRouteCard(); + } + + dragIdx = -1; + dragging = false; + lastOverEl = null; + lastOverPos = null; + } + + // Touch (mobile) + list.addEventListener('touchstart', (e) => { + const handle = e.target.closest('.wl-drag-handle'); + if (!handle) return; + startDrag(e.touches[0].clientY, parseInt(handle.dataset.idx, 10)); + }, { passive: true }); + + list.addEventListener('touchmove', (e) => { + if (dragIdx < 0) return; + moveDrag(e.touches[0].clientY); + e.preventDefault(); + }, { passive: false }); + + list.addEventListener('touchend', (e) => { + endDrag(e.changedTouches[0].clientY); + }, { passive: true }); + + // Mouse (desktop) + list.addEventListener('mousedown', (e) => { + const handle = e.target.closest('.wl-drag-handle'); + if (!handle) return; + if (e.target.closest('.wl-add')) return; // не перехватывать кнопку добавления + e.preventDefault(); + startDrag(e.clientY, parseInt(handle.dataset.idx, 10)); + document.addEventListener('mousemove', _onDragMouse); + document.addEventListener('mouseup', _onDropMouse); + }); + + function _onDragMouse(e) { + if (dragIdx < 0) return; + moveDrag(e.clientY); + } + + function _onDropMouse(e) { + endDrag(e.clientY); + document.removeEventListener('mousemove', _onDragMouse); + document.removeEventListener('mouseup', _onDropMouse); + } +} + +function removeWaypoint(idx) { + routeWaypoints.splice(idx, 1); + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) { + debounceBuildRoute(); + } else { + const map = window._map; + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + routeResults = []; + document.getElementById('route-cards').innerHTML = ''; + document.getElementById('route-status').textContent = + routeWaypoints.length === 0 ? 'Тапни точку старта на карте' : + routeWaypoints.length === 1 ? 'Тапни точку финиша' : ''; + } +} + +// ─── Построение маршрута ─────────────────────────────────────────── +function debounceBuildRoute() { + clearTimeout(buildDebounceTimer); + buildDebounceTimer = setTimeout(buildRoute, 300); +} + +async function buildRoute() { + if (routeWaypoints.length < 2) return; + const map = window._map; + const basePath = getBasePath(); + + // Show mini-bar with spinning wheel + showMiniRouteLoading(); + showRouteLoading(); + // Close main sheet if open + closeSheet('sheet-route'); + + document.getElementById('route-status').textContent = 'Строю маршрут...'; + showSkeleton('route-cards', 3); + + try { + const resp = await fetch(basePath + '/api/route', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ waypoints: routeWaypoints, alternatives: 5 }), + }); + if (!resp.ok) throw new Error('Маршрут не найден'); + const data = await resp.json(); + routeResults = data.routes || []; + if (!routeResults.length) throw new Error('Маршрут не найден'); + + drawRouteResults(routeResults, 0); + renderWaypointsList(); // update segment distances now that route is built + + document.getElementById('route-status').textContent = `${routeResults.length} маршрут(ов)`; + // Show mini-bar with result - do NOT open main sheet + hideMiniRouteLoading(); + showMiniRouteSheet(); + } catch(e) { + hideMiniRouteLoading(); + document.getElementById('route-status').textContent = '❌ ' + e.message; + document.getElementById('route-cards').innerHTML = ''; + const statsEl = document.getElementById('mini-stats'); + if (statsEl) statsEl.textContent = '❌ ' + e.message; + } +} + +function drawRouteResults(routes, activeIdx) { + const map = window._map; + activeRouteIdx = activeIdx; + const wasBuilt = routeResults.length > 0; // track rebuild vs first build + routeResults = routes; + + // Clear old layers + for (let i = 0; i < 5; i++) { + try { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); } catch(e) {} + try { if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); } catch(e) {} + try { if (map.getSource('route-' + i)) map.removeSource('route-' + i); } catch(e) {} + } + + routes.forEach((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const isActive = i === activeIdx; + map.addSource('route-' + i, { + type: 'geojson', + data: { type: 'Feature', geometry: route.geometry, properties: {} } + }); + map.addLayer({ + id: 'route-line-' + i + '-outline', + type: 'line', source: 'route-' + i, + paint: { + 'line-color': '#ffffff', + 'line-width': isActive ? 7 : 4, + 'line-opacity': isActive ? 0.6 : 0, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + map.addLayer({ + id: 'route-line-' + i, + type: 'line', source: 'route-' + i, + paint: { + 'line-color': color, + 'line-width': isActive ? 5 : 3, + 'line-opacity': isActive ? 0.95 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + map.on('click', 'route-line-' + i, (e) => { + if (e.stopPropagation) e.stopPropagation(); + selectRoute(i); + }); + map.on('mouseenter', 'route-line-' + i, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'route-line-' + i, () => { + map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; + }); + }); + + renderRouteCards(routes); + + // Auto-zoom to active route after drawing + const activeRoute = routes[activeIdx] || routes[0]; + if (activeRoute && activeRoute.geometry && activeRoute.geometry.coordinates) { + const coords = activeRoute.geometry.coordinates; + if (coords.length > 1) { + const bounds = coords.reduce( + (b, c) => b.extend(c), + new maplibregl.LngLatBounds(coords[0], coords[0]) + ); + map.fitBounds(bounds, { + padding: { top: 80, bottom: 160, left: 20, right: 20 }, + duration: 1200, + maxZoom: 14 + }); + } + } + + // Update mini sheet if visible + const miniEl = document.getElementById('sheet-route-mini'); + if (miniEl && miniEl.classList.contains('visible')) showMiniRouteSheet(); + + // Auto-minimize sheet on rebuild (not on first build) + if (wasBuilt) { + const sheet = document.getElementById('sheet-route'); + if (sheet && sheet.classList.contains('open')) { + minimizeSheet('sheet-route'); + } + } +} + +function selectRoute(idx) { + activeRouteIdx = idx; + const map = window._map; + routeResults.forEach((_, i) => { + const isActive = i === idx; + try { + if (map.getLayer('route-line-' + i)) { + map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); + map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5); + } + if (map.getLayer('route-line-' + i + '-outline')) { + map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4); + map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0); + } + } catch(e) {} + }); + document.querySelectorAll('.route-card').forEach((card, i) => { + card.classList.toggle('active', i === idx); + }); + renderWaypointsList(); +} + +// ─── Карточки маршрутов ─────────────────────────────────────────── +function renderRouteCards(routes) { + const container = document.getElementById('route-cards'); + container.innerHTML = routes.map((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const distKm = (route.distance_m / 1000).toFixed(1); + const timeStr = formatDuration(route.duration_s); + const isActive = i === activeRouteIdx; + const s = route.stats || {}; + const dirtPct = s.dirt_total_pct || 0; + const asphPct = s.asphalt_pct || 0; + + return `
+
+ + Вариант ${i + 1} + ${distKm} км · ${timeStr} +
+
+
+
+
+
+
+
${dirtPct}% грунт${asphPct ? ` · ${asphPct}% асфальт` : ''}
+
`; + }).join(''); +} + +// ─── GPX экспорт ─────────────────────────────────────────────────── +function escapeXml(str) { + return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function generateGPX() { + const route = routeResults[activeRouteIdx]; + if (!route) return ''; + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10); + const distKm = (route.distance_m / 1000).toFixed(1); + const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; + + const wpts = routeWaypoints.map((wp, i) => { + const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`; + return ` ${escapeXml(name)}`; + }); + const markers = loadMarkers(); + markers.forEach(m => { + wpts.push(` ${escapeXml(m.name)}${escapeXml(m.icon)}`); + }); + + const trkpts = route.geometry.coordinates.map(([lon, lat]) => + ` ` + ).join('\n'); + + return ` + + + Enduro route ${dateStr} + ${distKm} км · ${dirtPct}% грунт + + +${wpts.join('\n')} + + Enduro route ${dateStr} + +${trkpts} + + +`; +} + +function downloadGPX() { + const gpx = generateGPX(); + if (!gpx) return; + const now = new Date(); + const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15); + const filename = `enduro-${timeStr}.gpx`; + const blob = new Blob([gpx], { type: 'application/gpx+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +// ─── Флажки / именованные метки ──────────────────────────────────── +const MARKER_ICONS = ['🚩', '⛺', '🔧', '⛽', '💧', '📍']; +const MARKERS_KEY = 'enduro_markers'; +let markerMode = false; +let namedMarkerObjects = {}; + +function loadMarkers() { + try { return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]'); } catch(e) { return []; } +} +function saveMarkers(markers) { + try { localStorage.setItem(MARKERS_KEY, JSON.stringify(markers)); } catch(e) {} +} + +function toggleMarkerMode() { + markerMode = !markerMode; + const btn = document.getElementById('tb-marker'); + if (markerMode) { + deactivateAllModes(); + markerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + } + updateMapModeClass(); +} + +function addMarker(lngLat) { + const markers = loadMarkers(); + if (markers.length >= 50) { alert('Достигнут лимит 50 меток'); return; } + + const grid = document.getElementById('marker-type-grid'); + // Show marker dialog + openMarkerDialog(lngLat); +} + +function openMarkerDialog(lngLat) { + const dialog = document.getElementById('marker-dialog'); + const grid = document.getElementById('marker-type-grid'); + grid.innerHTML = MARKER_ICONS.map((ic, i) => + `` + ).join(''); + dialog.classList.add('open'); +} + +function closeMarkerDialog() { + document.getElementById('marker-dialog').classList.remove('open'); +} + +function selectMarkerType(idx, lat, lng) { + closeMarkerDialog(); + const markers = loadMarkers(); + const icon = MARKER_ICONS[idx] || MARKER_ICONS[0]; + const name = prompt('Название метки (Enter = автоимя):'); + if (name === null) return; + const autoName = name.trim() || `Метка ${markers.length + 1}`; + const marker = { id: Date.now(), name: autoName, icon, lat, lon: lng }; + markers.push(marker); + saveMarkers(markers); + drawNamedMarker(marker); +} + +function drawNamedMarker(markerData) { + const map = window._map; + if (!map) return; + const el = document.createElement('div'); + el.className = 'named-marker-el'; + el.textContent = markerData.icon; + el.title = markerData.name; + + const popup = new maplibregl.Popup({ offset: 25, closeButton: true }) + .setHTML(` + + +
+ + + +
+ `); + + const mlMarker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([markerData.lon, markerData.lat]) + .setPopup(popup) + .addTo(map); + + namedMarkerObjects[markerData.id] = mlMarker; +} + +function renderMarkers() { + const markers = loadMarkers(); + markers.forEach(m => drawNamedMarker(m)); +} + +function removeMarker(id) { + if (namedMarkerObjects[id]) { + const popup = namedMarkerObjects[id].getPopup(); + if (popup) popup.remove(); + namedMarkerObjects[id].remove(); + delete namedMarkerObjects[id]; + } + const markers = loadMarkers().filter(m => m.id !== id); + saveMarkers(markers); +} + +function useMarkerAsA(id) { + const markers = loadMarkers(); + const m = markers.find(x => x.id === id); + if (!m) return; + if (!routeMode) toggleRouteMode(); + if (routeWaypoints.length === 0) routeWaypoints.push({ lon: m.lon, lat: m.lat }); + else routeWaypoints[0] = { lon: m.lon, lat: m.lat }; + rebuildWaypointMarkers(); renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); +} + +function useMarkerAsB(id) { + const markers = loadMarkers(); + const m = markers.find(x => x.id === id); + if (!m) return; + if (!routeMode) toggleRouteMode(); + if (routeWaypoints.length === 0) { routeWaypoints.push({ lon: m.lon, lat: m.lat }); routeWaypoints.push({ lon: m.lon, lat: m.lat }); } + else if (routeWaypoints.length === 1) routeWaypoints.push({ lon: m.lon, lat: m.lat }); + else routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat }; + rebuildWaypointMarkers(); renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); +} + +// ─── Map init ────────────────────────────────────────────────────── +async function initMap() { + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const tileBase = window.location.origin + basePath; + const style = await fetch(basePath + '/style.json').then(r => r.json()); + style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`]; + + const map = new maplibregl.Map({ + container: 'map', + style: style, + center: [40.5, 55.5], + zoom: 7, + minZoom: 4, + maxZoom: 18, + hash: true, + }); + window._map = map; + + map.addControl(new maplibregl.NavigationControl(), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right'); + map.addControl(new maplibregl.FullscreenControl(), 'top-left'); + + map.on('load', () => { + checkDataAvailability(); + initRouteClicks(map); + initRulerClicks(map); + renderMarkers(); + // Apply theme on load + applyTheme(); + // Start auto-theme interval + themeAutoInterval = setInterval(() => { + if (themeMode === 'auto') applyAutoTheme(); + }, 60000); + }); + + map.on('style.load', () => { + onMapStyleLoad(); + }); + + map.on('error', (e) => { + console.error('Map error:', e.error?.message || e); + }); + + // Popup for trail features + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: false, + maxWidth: '300px', + }); + + function formatLength(m) { + if (!m) return '-'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; + } + + function poiTypeLabel(t) { + const labels = { + 'natural=peak': '⛰ Вершина', + 'natural=water': '💧 Вода', + 'tourism=viewpoint': '👁 Смотровая', + 'historic=ruins': '🏚 Руины', + 'natural=cave_entrance': '🕳 Пещера', + 'ford=yes': '🌊 Брод', + }; + return labels[t] || t; + } + + ['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => { + map.on('click', layerId, (e) => { + if (routeMode || rulerMode || markerMode) return; + const props = e.features[0].properties; + const html = ` + + + + + + ${props.mtb_scale ? `` : ''} + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', layerId, () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', layerId, () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; + }); + }); + + map.on('click', 'poi-circles', (e) => { + if (routeMode || rulerMode || markerMode) return; + const props = e.features[0].properties; + const html = ` + + + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', 'poi-circles', () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'poi-circles', () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; + }); + + map.on('click', (e) => { + if (routeMode || rulerMode || markerMode) return; + const features = map.queryRenderedFeatures(e.point, { + layers: ['trails-track', 'trails-path-bridleway', 'trails-asphalt', 'poi-circles'], + }); + if (!features.length) popup.remove(); + }); +} + +async function checkDataAvailability() { + try { + const basePath = getBasePath(); + const resp = await fetch(basePath + '/api/health'); + const data = await resp.json(); + if (!data.db_exists) { + document.getElementById('no-data-warning').classList.add('visible'); + } + } catch (e) { + console.warn('Health check failed:', e); + } +} + +// ─── Клики на карте ──────────────────────────────────────────────── +function initRouteClicks(map) { + map.on('click', (e) => { + const { lng, lat } = e.lngLat; + + if (reconMode) { doRecon(lng, lat); return; } + if (linkMode) { addLinkPoint(lng, lat); return; } + if (scenicMode) { + scenicStart = { lon: lng, lat: lat }; + document.getElementById('scenic-status').textContent = `📍 Старт: ${lat.toFixed(4)}, ${lng.toFixed(4)}`; + if (scenicStartMarker) scenicStartMarker.remove(); + const el = document.createElement('div'); + el.className = ''; + el.style.cssText = 'width:16px;height:16px;background:var(--accent);border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; + scenicStartMarker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map); + document.getElementById('btn-build-scenic').style.display = ''; + return; + } + if (markerMode) { + addMarker(e.lngLat); + toggleMarkerMode(); + return; + } + if (!routeMode) return; + + if (addingWaypoint) { + addingWaypoint = false; + map.getCanvas().style.cursor = 'crosshair'; + if (routeWaypoints.length >= 2) { + routeWaypoints.splice(routeWaypoints.length - 1, 0, { lon: lng, lat: lat }); + } else { + routeWaypoints.push({ lon: lng, lat: lat }); + } + hideMiniOnboard(); + rebuildWaypointMarkers(); renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + updateMiniRouteCard(); + return; + } + + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); renderWaypointsList(); + document.getElementById('route-status').textContent = 'Тапни точку финиша'; + showRouteOnboardingMini(); // switch to finish prompt + } else if (routeWaypoints.length === 1) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); renderWaypointsList(); + hideMiniOnboard(); + buildRoute(); + } + }); +} + +// ─── Поиск (Nominatim) ───────────────────────────────────────────── +let searchTimeout = null; + +// ─── Waypoint inline search ──────────────────────────────────────── +let wpSearchTimeout = null; + +function openWaypointSearch(idx) { + // Close all other open panels + document.querySelectorAll('.wl-search-panel').forEach(p => { + if (p.id !== `wl-search-panel-${idx}`) p.style.display = 'none'; + }); + const panel = document.getElementById(`wl-search-panel-${idx}`); + if (!panel) return; + const isOpen = panel.style.display !== 'none'; + panel.style.display = isOpen ? 'none' : 'block'; + if (!isOpen) { + const input = document.getElementById(`wl-search-input-${idx}`); + if (input) { + input.value = ''; + input.focus(); + input.addEventListener('input', () => { + clearTimeout(wpSearchTimeout); + const q = input.value.trim(); + if (q.length < 2) { + document.getElementById(`wl-search-results-${idx}`).innerHTML = ''; + return; + } + wpSearchTimeout = setTimeout(() => doWaypointSearch(idx, q), 400); + }); + } + } +} + +async function doWaypointSearch(idx, query) { + const resultsEl = document.getElementById(`wl-search-results-${idx}`); + if (!resultsEl) return; + resultsEl.innerHTML = '
Поиск...
'; + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&countrycodes=ru&accept-language=ru`; + const resp = await fetch(url); + const data = await resp.json(); + if (!data.length) { + resultsEl.innerHTML = '
Ничего не найдено
'; + return; + } + resultsEl.innerHTML = data.map(item => { + const parts = (item.display_name || '').split(', '); + const name = parts[0]; + const sub = parts.slice(1, 3).join(', '); + return `
+
${name}
+ ${sub ? `
${sub}
` : ''} +
`; + }).join(''); + } catch(e) { + resultsEl.innerHTML = '
Ошибка поиска
'; + } +} + +function selectWaypointSearchResult(idx, lat, lon, name) { + routeWaypoints[idx] = { lat: parseFloat(lat), lon: parseFloat(lon) }; + const panel = document.getElementById(`wl-search-panel-${idx}`); + if (panel) panel.style.display = 'none'; + rebuildWaypointMarkers(); + renderWaypointsList(); + window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 600 }); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + updateMiniRouteCard(); +} + +function initSearch() { + const input = document.getElementById('search-input'); + const results = document.getElementById('search-results'); + input.addEventListener('input', () => { + clearTimeout(searchTimeout); + const q = input.value.trim(); + if (q.length < 2) { results.style.display = 'none'; return; } + searchTimeout = setTimeout(() => doSearch(q), 400); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { results.style.display = 'none'; input.blur(); } + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('#search-bar') && !e.target.closest('#search-results')) { + results.style.display = 'none'; + } + }); +} + +async function doSearch(query) { + const results = document.getElementById('search-results'); + results.innerHTML = '
Поиск...
'; + results.style.display = 'block'; + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`; + const resp = await fetch(url, { headers: { 'Accept-Language': 'ru' } }); + const data = await resp.json(); + if (!data.length) { + results.innerHTML = '
Ничего не найдено
'; + return; + } + results.innerHTML = data.map((item) => { + const name = item.display_name.split(',')[0]; + const detail = item.display_name.split(',').slice(1, 3).join(',').trim(); + return `
+
${name}
+
${detail}
+
`; + }).join(''); + } catch(e) { + results.innerHTML = '
Ошибка поиска
'; + } +} + +function selectSearchResult(lat, lon, name) { + window._map.flyTo({ center: [lon, lat], zoom: 13, duration: 800 }); + document.getElementById('search-results').style.display = 'none'; + document.getElementById('search-input').value = name; +} + +// ─── Линейка ─────────────────────────────────────────────────────── +let rulerMode = false; +let rulerPoints = []; +let rulerMarkers = []; +let rulerTotal = 0; + +function toggleRuler() { + const btn = document.getElementById('tb-ruler'); + + if (rulerMode) { + // Режим активен → выйти из режима, скрыть линейку (точки сохраняются в rulerPoints) + rulerMode = false; + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + // Убрать маркеры с карты + удалить DOM-элементы + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + try { if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); } catch(e) {} + try { if (map.getSource('ruler')) map.removeSource('ruler'); } catch(e) {} + document.getElementById('ruler-info').classList.remove('visible'); + updateMapModeClass(); + + } else if (rulerPoints.length > 0) { + // Линейка скрыта, точки есть → восстановить и войти в режим рисования + deactivateAllModes(); + rulerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + const pts = [...rulerPoints]; + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); // удалить старые маркеры с карты + rulerMarkers = []; + pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); + document.getElementById('ruler-info').classList.add('visible'); + updateMapModeClass(); + + } else { + // Нет линейки → войти в режим рисования + deactivateAllModes(); + rulerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + showRulerToast(); + updateMapModeClass(); + } +} + +function deleteRuler() { + rulerMode = false; + const btn = document.getElementById('tb-ruler'); + if (btn) btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + clearRuler(); + document.getElementById('ruler-info').classList.remove('visible'); + updateMapModeClass(); +} + +// Fix 4: Toast hint helper +function showRulerToast() { + const toast = document.getElementById('ruler-toast'); + if (!toast) return; + toast.classList.add('visible'); + setTimeout(() => toast.classList.remove('visible'), 3000); +} + +// Exit ruler mode without clearing points/markers ("Завершить") +function exitRulerMode() { + if (!rulerMode) return; + rulerMode = false; + const btn = document.getElementById('tb-ruler'); + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + document.getElementById('ruler-info').classList.remove('visible'); + updateMapModeClass(); +} + +function clearRuler() { + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + try { if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); } catch(e) {} + try { if (map.getSource('ruler')) map.removeSource('ruler'); } catch(e) {} +} + +function haversineKm(a, b) { + const R = 6371; + const dLat = (b[1] - a[1]) * Math.PI / 180; + const dLon = (b[0] - a[0]) * Math.PI / 180; + const s = Math.sin(dLat/2)**2 + Math.cos(a[1]*Math.PI/180) * Math.cos(b[1]*Math.PI/180) * Math.sin(dLon/2)**2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); +} + +function updateRulerLine() { + const map = window._map; + const geojson = { type: 'Feature', geometry: { type: 'LineString', coordinates: rulerPoints } }; + if (map.getSource('ruler')) { + map.getSource('ruler').setData(geojson); + } else { + map.addSource('ruler', { type: 'geojson', data: geojson }); + map.addLayer({ + id: 'ruler-line', type: 'line', source: 'ruler', + paint: { 'line-color': '#0088ff', 'line-width': 2, 'line-dasharray': [4, 2], 'line-opacity': 0.9 } + }); + } + // Update ruler info display + const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + document.getElementById('ruler-dist').textContent = dist; +} + +function removeRulerPoint(idx) { + const map = window._map; + // Remove marker from map + rulerMarkers[idx].remove(); + rulerMarkers.splice(idx, 1); + rulerPoints.splice(idx, 1); + // Recalculate total and update all labels + updateRulerLabels(); + updateRulerLine(); +} + +function updateRulerLabels() { + // Recalculate rulerTotal from scratch and update label elements on each marker + rulerTotal = 0; + for (let i = 0; i < rulerMarkers.length; i++) { + const markerEl = rulerMarkers[i].getElement(); + const dot = markerEl.querySelector('.ruler-dot'); + const label = markerEl.querySelector('.ruler-label'); + const btn = markerEl.querySelector('.ruler-remove-btn'); + const labelText = label ? label.querySelector('span') : null; + + // Fix 5: Update dot color for first point + if (dot) { + const dotColor = i === 0 ? '#2EA043' : '#0088ff'; + dot.style.background = dotColor; + } + + if (i === 0) { + if (labelText) labelText.textContent = 'Старт'; + } else { + const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]); + rulerTotal += segDist; + if (labelText) { + labelText.textContent = segDist >= 1 + ? segDist.toFixed(1) + ' км' + : Math.round(segDist * 1000) + ' м'; + } + } + // Update remove button index + if (btn) { + btn.onclick = (e) => { e.stopPropagation(); removeRulerPoint(i); }; + } + } + // Update total display + const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + document.getElementById('ruler-dist').textContent = dist; +} + +function addRulerPoint(lngLat) { + const map = window._map; + const pt = [lngLat.lng, lngLat.lat]; + const idx = rulerPoints.length; + rulerPoints.push(pt); + + let segDist = 0; + if (idx > 0) { + segDist = haversineKm(rulerPoints[idx - 1], pt); + rulerTotal += segDist; + } + + // Bug 3: hide toast on ANY tap, not just the first + const toast = document.getElementById('ruler-toast'); + if (toast) toast.classList.remove('visible'); + + // Bug 4: show ruler-info panel after first point is added + if (idx === 0) { + document.getElementById('ruler-info').classList.add('visible'); + } + + // Wrapper element for dot + label row + // Bug 6: wrapper is flex-column; label is absolute below dot so anchor:'center' hits the dot + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;display:flex;flex-direction:column;align-items:center;'; + + // Dot - first point green + const dot = document.createElement('div'); + dot.className = 'ruler-dot'; + const dotColor = idx === 0 ? '#2EA043' : '#0088ff'; + dot.style.cssText = `width:10px;height:10px;background:${dotColor};border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3);display:block;flex-shrink:0;`; + + // Label row: [distance text][× button] — absolutely positioned below dot + const label = document.createElement('div'); + label.className = 'ruler-label'; + label.style.cssText = 'position:absolute;top:100%;margin-top:3px;display:flex;align-items:center;gap:4px;background:rgba(20,20,20,0.75);color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;white-space:nowrap;'; + + const labelText = document.createElement('span'); + if (idx === 0) { + labelText.textContent = 'Старт'; + } else { + labelText.textContent = segDist >= 1 + ? segDist.toFixed(1) + ' км' + : Math.round(segDist * 1000) + ' м'; + } + + // Bug 5: use button element for better tap target and semantics + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ruler-remove-btn'; + btn.textContent = '×'; + btn.style.cssText = 'background:none;border:none;cursor:pointer;font-size:16px;line-height:1;opacity:0.85;padding:4px 8px;min-width:32px;min-height:32px;display:flex;align-items:center;justify-content:center;margin:-2px -6px -2px 0;color:#fff;'; + btn.onclick = (e) => { e.stopPropagation(); removeRulerPoint(idx); }; + + label.appendChild(labelText); + label.appendChild(btn); + + wrapper.appendChild(dot); + wrapper.appendChild(label); + + // Bug 2: tap on marker wrapper resumes ruler mode + wrapper.addEventListener('click', (e) => { + e.stopPropagation(); + if (!rulerMode && rulerPoints.length > 0) { + rulerMode = true; + document.getElementById('tb-ruler').classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + updateMapModeClass(); + } + document.getElementById('ruler-info').classList.add('visible'); + }); + + const dotMarker = new maplibregl.Marker({ element: wrapper, anchor: 'center' }) + .setLngLat([lngLat.lng, lngLat.lat]) + .addTo(map); + rulerMarkers.push(dotMarker); + updateRulerLine(); +} + +function initRulerClicks(map) { + map.on('click', (e) => { + if (!rulerMode) return; + addRulerPoint(e.lngLat); + }); + map.on('dblclick', (e) => { + if (!rulerMode) return; + e.preventDefault(); + exitRulerMode(); + }); + // Fix 2 & 6: tap on ruler line shows panel AND resumes ruler mode + map.on('click', 'ruler-line', (e) => { + e.originalEvent.stopPropagation(); + if (rulerPoints.length > 0) { + // Fix 6: Resume ruler mode + if (!rulerMode) { + rulerMode = true; + const btn = document.getElementById('tb-ruler'); + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + updateMapModeClass(); + } + document.getElementById('ruler-info').classList.add('visible'); + } + }); +} + +// ─── Фаза 4: Разведка ───────────────────────────────────────────── +let reconMode = false; +let reconCenter = null; +let reconRadius = 20; + +function toggleReconMode() { + const btn = document.getElementById('tb-recon'); + if (reconMode) { + // Exit recon mode + reconMode = false; + btn.classList.remove('active'); + closeSheet('sheet-recon'); + window._map.getCanvas().style.cursor = ''; + clearRecon(); // recon data is transient - safe to clear + } else { + deactivateAllModes(); + reconMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + openSheet('sheet-recon'); + } + updateMapModeClass(); +} + +function makeCircleGeoJSON(lon, lat, radiusKm) { + const coords = []; + for (let i = 0; i <= 64; i++) { + const a = (2 * Math.PI * i) / 64; + const dlat = (radiusKm / 111) * Math.cos(a); + const dlon = (radiusKm / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(a); + coords.push([lon + dlon, lat + dlat]); + } + return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: {} }; +} + +async function doRecon(lon, lat) { + reconCenter = [lon, lat]; + const map = window._map; + + const circle = makeCircleGeoJSON(lon, lat, reconRadius); + if (map.getSource('recon-circle')) { + map.getSource('recon-circle').setData(circle); + } else { + map.addSource('recon-circle', { type: 'geojson', data: circle }); + map.addLayer({ + id: 'recon-circle-fill', type: 'fill', source: 'recon-circle', + paint: { 'fill-color': '#ff6600', 'fill-opacity': 0.08 } + }); + map.addLayer({ + id: 'recon-circle-stroke', type: 'line', source: 'recon-circle', + paint: { 'line-color': '#ff6600', 'line-width': 2, 'line-opacity': 0.5 } + }); + } + + const basePath = getBasePath(); + const resultsDiv = document.getElementById('recon-results'); + resultsDiv.style.display = 'block'; + + try { + const resp = await fetch(`${basePath}/api/recon`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lon, lat, radius_km: reconRadius }) + }); + const data = await resp.json(); + const t = data.trails || {}; + const p = data.poi || {}; + + document.getElementById('r-total-km').textContent = t.total_km || 0; + document.getElementById('r-lev12-km').textContent = t.lev12_km || 0; + document.getElementById('r-lev345-km').textContent = t.lev345_km || 0; + document.getElementById('r-path-km').textContent = t.path_km || 0; + + const poiList = document.getElementById('r-poi-list'); + const poiTypes = [ + { key: 'natural=water', icon: '💧', label: 'Озёра' }, + { key: 'tourism=viewpoint', icon: '👁', label: 'Смотровая' }, + { key: 'ford=yes', icon: '🌊', label: 'Броды' }, + { key: 'historic=ruins', icon: '🏚', label: 'Руины' }, + ]; + poiList.innerHTML = poiTypes.map(pt => + `
+ ${pt.icon} ${pt.label} + ${p[pt.key] || 0} +
` + ).join(''); + + } catch(e) { + document.getElementById('r-total-km').textContent = '-'; + } +} + +function setReconRadius(km) { + reconRadius = km; + document.querySelectorAll('.seg-btn[data-km]').forEach(b => { + b.classList.toggle('active', +b.dataset.km === km); + }); + if (reconCenter) doRecon(reconCenter[0], reconCenter[1]); +} + +function clearRecon() { + const map = window._map; + try { if (map.getLayer('recon-circle-fill')) map.removeLayer('recon-circle-fill'); } catch(e) {} + try { if (map.getLayer('recon-circle-stroke')) map.removeLayer('recon-circle-stroke'); } catch(e) {} + try { if (map.getSource('recon-circle')) map.removeSource('recon-circle'); } catch(e) {} + closeSheet('sheet-recon'); + reconCenter = null; +} + +// ─── Фаза 4: Связка ──────────────────────────────────────────────── +let linkMode = false; +let linkPoints = []; +let linkMarkers = []; + +function toggleLinkMode() { + const btn = document.getElementById('tb-link'); + if (linkMode) { + // Exit link mode + linkMode = false; + btn.classList.remove('active'); + closeSheet('sheet-link'); + window._map.getCanvas().style.cursor = ''; + clearLink(); + } else { + deactivateAllModes(); + linkMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + openSheet('sheet-link'); + document.getElementById('link-status').textContent = '1️⃣ Тапни конец первого трека'; + document.getElementById('link-cards').innerHTML = ''; + linkPoints = []; + linkMarkers.forEach(m => m.remove()); + linkMarkers = []; + } + updateMapModeClass(); +} + +function addLinkPoint(lng, lat) { + const map = window._map; + linkPoints.push({ lon: lng, lat: lat }); + const idx = linkPoints.length; + const el = document.createElement('div'); + el.className = ''; + el.style.cssText = 'width:16px;height:16px;background:var(--accent);border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;'; + el.textContent = idx; + const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }).setLngLat([lng, lat]).addTo(map); + linkMarkers.push(marker); + + if (idx === 1) { + document.getElementById('link-pt-1').classList.remove('empty'); + document.getElementById('link-pt-1').querySelector('.link-pt-label').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + document.getElementById('link-status').textContent = '2️⃣ Тапни начало второго трека'; + } else if (idx >= 2) { + document.getElementById('link-pt-2').classList.remove('empty'); + document.getElementById('link-pt-2').querySelector('.link-pt-label').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + showSkeleton('link-cards', 2); + buildLinkRoute(); + } +} + +async function buildLinkRoute() { + const map = window._map; + document.getElementById('link-status').textContent = '⏳ Ищу связку...'; + const basePath = getBasePath(); + try { + const resp = await fetch(`${basePath}/api/route`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ waypoints: linkPoints, alternatives: 3 }) + }); + if (!resp.ok) throw new Error('Не найдена'); + const data = await resp.json(); + if (data.routes && data.routes.length > 0) { + renderLinkCards(data.routes); + document.getElementById('link-status').textContent = '✅ Связка найдена'; + } else { + document.getElementById('link-status').textContent = '❌ Грунтовая связка не найдена'; + } + } catch(e) { + document.getElementById('link-status').textContent = '❌ ' + e.message; + } +} + +function renderLinkCards(routes) { + const map = window._map; + const colors = ['#0066ff', '#00aa44', '#9933cc']; + const cardsEl = document.getElementById('link-cards'); + cardsEl.innerHTML = ''; + + routes.forEach((r, i) => { + const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; + const sid = `link-src-${i}`; + const lid = `link-line-${i}`; + try { if (map.getSource(sid)) map.removeSource(sid); } catch(e) {} + try { if (map.getLayer(lid)) map.removeLayer(lid); } catch(e) {} + map.addSource(sid, { type: 'geojson', data: geojson }); + map.addLayer({ + id: lid, type: 'line', source: sid, + paint: { + 'line-color': colors[i % colors.length], + 'line-width': i === 0 ? 5 : 3, + 'line-opacity': i === 0 ? 0.9 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + const km = (r.distance_m / 1000).toFixed(0); + const time = formatDuration(r.duration_s); + const dirt = r.stats?.dirt_total_pct || '?'; + const col = colors[i % colors.length]; + const card = document.createElement('div'); + card.className = 'route-card' + (i === 0 ? ' active' : ''); + card.innerHTML = ` +
+ + Вариант ${i+1} + ${km} км + ${time} +
+
${dirt}% грунт
+ `; + card.onclick = () => selectLinkRoute(i); + cardsEl.appendChild(card); + }); +} + +function selectLinkRoute(idx) { + const map = window._map; + document.querySelectorAll('#link-cards .route-card').forEach((c, i) => c.classList.toggle('active', i === idx)); + for (let i = 0; i < 3; i++) { + const lid = `link-line-${i}`; + try { + if (map.getLayer(lid)) { + map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); + map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); + } + } catch(e) {} + } +} + +function clearLink() { + linkPoints = []; + linkMarkers.forEach(m => m.remove()); + linkMarkers = []; + const map = window._map; + for (let i = 0; i < 5; i++) { + try { if (map.getLayer(`link-line-${i}`)) map.removeLayer(`link-line-${i}`); } catch(e) {} + try { if (map.getSource(`link-src-${i}`)) map.removeSource(`link-src-${i}`); } catch(e) {} + } + closeSheet('sheet-link'); + document.getElementById('link-cards').innerHTML = ''; + // Reset link point UI + const pt1 = document.getElementById('link-pt-1'); + const pt2 = document.getElementById('link-pt-2'); + if (pt1) { pt1.classList.add('empty'); pt1.querySelector('.link-pt-label').textContent = 'Конец первого трека'; } + if (pt2) { pt2.classList.add('empty'); pt2.querySelector('.link-pt-label').textContent = 'Начало второго трека'; } +} + +// ─── Фаза 4: Красивый маршрут ────────────────────────────────────── +let scenicMode = false; +let scenicStart = null; +let scenicStartMarker = null; +let scenicTargetKm = 100; +let scenicRoutes = []; +let activeScenicIdx = 0; + +function toggleScenicMode() { + const btn = document.getElementById('tb-scenic'); + if (scenicMode) { + // Exit scenic mode + scenicMode = false; + btn.classList.remove('active'); + closeSheet('sheet-scenic'); + window._map.getCanvas().style.cursor = ''; + clearScenic(); + } else { + deactivateAllModes(); + scenicMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + openSheet('sheet-scenic'); + document.getElementById('scenic-status').textContent = 'Тапни точку старта на карте'; + document.getElementById('btn-build-scenic').style.display = 'none'; + } + updateMapModeClass(); +} + +function setScenicKm(km) { + scenicTargetKm = km; + document.querySelectorAll('#sheet-scenic .seg-btn[data-km]').forEach(b => { + b.classList.toggle('active', +b.dataset.km === km); + }); + const inp = document.getElementById('scenic-custom-km'); + if (inp) inp.value = km; +} + +async function buildScenicRoute() { + if (!scenicStart) return; + const map = window._map; + document.getElementById('scenic-status').textContent = '⏳ Строю красивый маршрут...'; + showSkeleton('scenic-cards', 2); + const btn = document.getElementById('btn-build-scenic'); + btn.disabled = true; + + const basePath = getBasePath(); + try { + const resp = await fetch(`${basePath}/api/scenic`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lon: scenicStart.lon, lat: scenicStart.lat, target_km: scenicTargetKm }) + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || 'Ошибка'); + } + const data = await resp.json(); + scenicRoutes = data.routes || []; + if (scenicRoutes.length === 0) throw new Error('Маршрут не найден'); + + drawScenicRoutes(scenicRoutes, 0); + + document.getElementById('scenic-status').textContent = `✅ ${scenicRoutes.length} маршрут(ов)`; + } catch(e) { + document.getElementById('scenic-status').textContent = '❌ ' + e.message; + document.getElementById('scenic-cards').innerHTML = ''; + } + btn.disabled = false; +} + +function drawScenicRoutes(routes, activeIdx) { + const map = window._map; + scenicRoutes = routes; + activeScenicIdx = activeIdx; + + // Clear old + for (let i = 0; i < 10; i++) { + try { if (map.getLayer(`scenic-line-${i}`)) map.removeLayer(`scenic-line-${i}`); } catch(e) {} + try { if (map.getSource(`scenic-src-${i}`)) map.removeSource(`scenic-src-${i}`); } catch(e) {} + } + + const colors = ['#0066ff', '#00aa44', '#9933cc']; + routes.forEach((r, i) => { + const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; + const sid = `scenic-src-${i}`; + const lid = `scenic-line-${i}`; + map.addSource(sid, { type: 'geojson', data: geojson }); + map.addLayer({ + id: lid, type: 'line', source: sid, + paint: { + 'line-color': colors[i % colors.length], + 'line-width': i === activeIdx ? 5 : 3, + 'line-opacity': i === activeIdx ? 0.9 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + }); + + const cardsEl = document.getElementById('scenic-cards'); + if (cardsEl) { + cardsEl.innerHTML = routes.map((r, i) => { + const col = colors[i % colors.length]; + const km = (r.distance_m / 1000).toFixed(0); + const time = formatDuration(r.duration_s); + const dirt = r.stats?.dirt_total_pct || '?'; + const pois = (r.scenic_pois || []).map(p => { + const SCENIC_LABELS = {'natural=water':'💧 Озёро','tourism=viewpoint':'👁 Смотровая','historic=ruins':'🏚 Руины','natural=peak':'🔺 Вершина','natural=cave_entrance':'🕳 Пещера','ford=yes':'🌊 Брод'}; + const label = SCENIC_LABELS[p.type] || '📍 ' + p.type; + const name = p.name ? ` - ${p.name}` : ''; + return `
${label}${name}
`; + }).join(''); + return `
+
+ + ${r.name || 'Вариант '+(i+1)} + ${km} км + ${time} +
+
${dirt}% грунт · score=${r.scenic_score||0}
+ ${pois ? '
'+pois+'
' : ''} +
`; + }).join(''); + } +} + +function selectScenicRoute(idx) { + activeScenicIdx = idx; + const map = window._map; + scenicRoutes.forEach((_, i) => { + const lid = `scenic-line-${i}`; + try { + if (map.getLayer(lid)) { + map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); + map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); + } + } catch(e) {} + }); + document.querySelectorAll('#scenic-cards .route-card').forEach((c, i) => { + c.classList.toggle('active', i === idx); + }); +} + +function clearScenic() { + const map = window._map; + for (let i = 0; i < 10; i++) { + try { if (map.getLayer(`scenic-line-${i}`)) map.removeLayer(`scenic-line-${i}`); } catch(e) {} + try { if (map.getSource(`scenic-src-${i}`)) map.removeSource(`scenic-src-${i}`); } catch(e) {} + } + if (scenicStartMarker) { scenicStartMarker.remove(); scenicStartMarker = null; } + scenicStart = null; + scenicRoutes = []; + closeSheet('sheet-scenic'); + const cardsEl = document.getElementById('scenic-cards'); + if (cardsEl) cardsEl.innerHTML = ''; +} + +// ─── Init on page load ───────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + initMap(); + initSheetSwipe(); + // Apply saved theme immediately (before map loads) + applyTheme(); +}); + +// ─── Mini Route Bar ────────────────────────────────────────────────── + +function miniAddWaypoint() { + // Enter waypoint-adding mode without opening full sheet + if (!routeMode) { + routeMode = true; + document.getElementById('tb-route').classList.add('active'); + updateMapModeClass(); + } + addWaypointMode(); + // Show hint on mini-bar + const statsEl = document.getElementById('mini-stats'); + if (statsEl) statsEl.textContent = 'Тапни на карте для добавления точки'; +} + +window.cancelAddWaypoint = cancelAddWaypoint; +function cancelAddWaypoint() { + addingWaypoint = false; + window._map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; + hideMiniOnboard(); + // Просто показать мини-бар без открытия листа + if (routeResults.length > 0) { + // Показать мини-бар с результатом маршрута (без открытия листа) + updateMiniRouteCard(); + document.getElementById('sheet-route-mini').classList.add('visible'); + const ctrl = document.getElementById('map-controls-r'); + if (ctrl) ctrl.style.bottom = '148px'; + initMiniRouteInteraction(); + } else if (routeWaypoints.length > 0) { + showRouteOnboardingMini(); + } else { + hideMiniRouteSheet(); + } +} + +function showRouteOnboardingMini() { + const cancelBtn = document.getElementById('mini-onboard-cancel-btn'); + if (cancelBtn) cancelBtn.style.display = 'none'; + + if (routeWaypoints.length >= 2) { + hideMiniOnboard(); + showMiniRouteSheet(); + return; + } + const isStart = routeWaypoints.length === 0; + const label = isStart ? 'S' : 'F'; + const color = isStart ? '#2EA043' : '#FF3B1F'; + const hint = isStart ? 'Тапни на карте — старт' : 'Тапни на карте — финиш'; + + // Show onboarding div, hide normal mini-bar content + document.getElementById('mini-onboard').style.display = 'flex'; + document.getElementById('mini-dot').style.display = 'none'; + document.getElementById('mini-label').style.display = 'none'; + document.getElementById('mini-stats').style.display = 'none'; + document.getElementById('mini-wheel').style.display = 'none'; + const arrows = document.querySelector('.mini-route-arrows'); + if (arrows) arrows.style.display = 'none'; + const addBtn = document.getElementById('mini-add-btn'); + if (addBtn) addBtn.style.display = 'none'; + + // Set pin and hint + document.getElementById('mini-onboard-pin').innerHTML = waypointPinSvg(label, color); + document.getElementById('mini-onboard-hint').textContent = hint; + + // Show mini-bar and raise map controls + document.getElementById('sheet-route-mini').classList.add('visible'); + const ctrl = document.getElementById('map-controls-r'); + if (ctrl) ctrl.style.bottom = '148px'; + + // Search button handler + const searchBtn = document.getElementById('mini-onboard-search-btn'); + searchBtn.onclick = () => toggleMiniOnboardSearch(isStart ? 'start' : 'finish'); +} + +function hideMiniOnboard() { + document.getElementById('mini-onboard').style.display = 'none'; + document.getElementById('mini-onboard-search-panel').style.display = 'none'; + const cancelBtn = document.getElementById('mini-onboard-cancel-btn'); + if (cancelBtn) cancelBtn.style.display = 'none'; + // Restore normal mini-bar elements + document.getElementById('mini-dot').style.display = ''; + document.getElementById('mini-label').style.display = ''; + document.getElementById('mini-stats').style.display = ''; + document.getElementById('mini-wheel').style.display = ''; + const arrows = document.querySelector('.mini-route-arrows'); + if (arrows) arrows.style.display = ''; + const addBtn = document.getElementById('mini-add-btn'); + if (addBtn) addBtn.style.display = ''; +} + +function toggleMiniOnboardSearch(type) { + const panel = document.getElementById('mini-onboard-search-panel'); + const isVisible = panel.style.display !== 'none'; + if (isVisible) { + panel.style.display = 'none'; + return; + } + panel.style.display = 'block'; + const input = document.getElementById('mini-onboard-search-input'); + input.value = ''; + document.getElementById('mini-onboard-search-results').innerHTML = ''; + setTimeout(() => input.focus(), 50); + + let timeout = null; + input.oninput = () => { + clearTimeout(timeout); + const q = input.value.trim(); + const resultsEl = document.getElementById('mini-onboard-search-results'); + if (q.length < 2) { resultsEl.innerHTML = ''; return; } + timeout = setTimeout(() => _doMiniOnboardSearch(type, q, resultsEl), 400); + }; +} + +async function _doMiniOnboardSearch(type, query, resultsEl) { + resultsEl.innerHTML = '
Поиск...
'; + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&accept-language=ru`; + const resp = await fetch(url); + const data = await resp.json(); + if (!data.length) { + resultsEl.innerHTML = '
Ничего не найдено
'; + return; + } + resultsEl.innerHTML = data.map(item => { + const parts = (item.display_name || '').split(', '); + const name = parts[0]; + const sub = parts.slice(1, 3).join(', '); + return `
+
${name}
+ ${sub ? `
${sub}
` : ''} +
`; + }).join(''); + } catch(e) { + resultsEl.innerHTML = '
Ошибка
'; + } +} + +function _selectMiniOnboardResult(type, lat, lon, name) { + const wp = { lat: parseFloat(lat), lon: parseFloat(lon) }; + if (type === 'start') { + routeWaypoints.unshift(wp); + } else if (type === 'finish') { + routeWaypoints.push(wp); + } else if (type === 'waypoint') { + // Insert before last waypoint (same as addingWaypoint tap logic) + if (routeWaypoints.length >= 2) { + routeWaypoints.splice(routeWaypoints.length - 1, 0, wp); + } else { + routeWaypoints.push(wp); + } + addingWaypoint = false; + } + document.getElementById('mini-onboard-search-panel').style.display = 'none'; + rebuildWaypointMarkers(); + window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 12, duration: 600 }); + + if (routeWaypoints.length >= 2) { + hideMiniOnboard(); + debounceBuildRoute(); + } else { + showRouteOnboardingMini(); // switch to finish + } + updateMiniRouteCard(); +} + +function showMiniRouteSheet() { + if (!routeResults || routeResults.length === 0) return; + updateMiniRouteCard(); + document.getElementById('sheet-route-mini').classList.add('visible'); + // Поднять кнопки карты над мини-баром (64px высота + 72px bottom + 8px отступ) + const ctrl = document.getElementById('map-controls-r'); + if (ctrl) ctrl.style.bottom = '148px'; + initMiniRouteInteraction(); +} + +function hideMiniRouteSheet() { + const el = document.getElementById('sheet-route-mini'); + if (el) el.classList.remove('visible'); + // Вернуть кнопки на место + const ctrl = document.getElementById('map-controls-r'); + if (ctrl) ctrl.style.bottom = ''; +} + +function updateMiniRouteCard() { + const r = routeResults[activeRouteIdx]; + if (!r) return; + const km = (r.distance_m / 1000).toFixed(1); + const dirt = r.stats?.dirt_total_pct ?? '-'; + document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length]; + document.getElementById('mini-label').textContent = `Вариант ${activeRouteIdx + 1} из ${routeResults.length}`; + document.getElementById('mini-stats').textContent = `${km} км · ${dirt}% грунт`; + document.getElementById('mini-prev').style.opacity = activeRouteIdx > 0 ? '1' : '0.3'; + document.getElementById('mini-next').style.opacity = activeRouteIdx < routeResults.length - 1 ? '1' : '0.3'; +} + +function selectMiniRoute(idx) { + if (idx < 0 || idx >= routeResults.length) return; + activeRouteIdx = idx; + const map = window._map; + for (let i = 0; i < routeResults.length; i++) { + const op = i === idx ? 1 : 0.35; + try { + if (map.getLayer('route-line-' + i)) map.setPaintProperty('route-line-' + i, 'line-opacity', op); + if (map.getLayer('route-line-' + i + '-outline')) map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', i === idx ? 0.6 : 0); + } catch(e) {} + } + updateMiniRouteCard(); + renderWaypointsList(); +} + +// ─── Route Loading Indicators ──────────────────────────────────── +function showRouteLoading() { + const el = document.getElementById('route-cards'); + if (el) el.innerHTML = `
+
+ Строю маршрут... +
`; +} + +function showMiniRouteLoading() { + const wheel = document.getElementById('mini-wheel'); + const statsEl = document.getElementById('mini-stats'); + if (wheel) wheel.classList.add('spinning'); + if (statsEl) statsEl.textContent = 'Строю маршрут...'; + document.getElementById('sheet-route-mini').classList.add('visible'); +} + +function hideMiniRouteLoading() { + const wheel = document.getElementById('mini-wheel'); + if (wheel) wheel.classList.remove('spinning'); +} + +function initMiniRouteInteraction() { + const mini = document.getElementById('sheet-route-mini'); + if (!mini) return; + + // Replace element to drop all old listeners + const newMini = mini.cloneNode(true); + mini.parentNode.replaceChild(newMini, mini); + + // Re-bind arrow buttons + document.getElementById('mini-prev').onclick = (e) => { e.stopPropagation(); selectMiniRoute(activeRouteIdx - 1); }; + document.getElementById('mini-next').onclick = (e) => { e.stopPropagation(); selectMiniRoute(activeRouteIdx + 1); }; + const addBtn = document.getElementById('mini-add-btn'); + if (addBtn) addBtn.onclick = (e) => { e.stopPropagation(); miniAddWaypoint(); }; + + let startX = 0, startY = 0; + newMini.addEventListener('touchstart', e => { + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + }, { passive: true }); + + newMini.addEventListener('touchend', e => { + const dx = e.changedTouches[0].clientX - startX; + const dy = e.changedTouches[0].clientY - startY; + if (Math.abs(dy) > Math.abs(dx)) { + if (dy < -40) { hideMiniRouteSheet(); openSheet('sheet-route'); selectRoute(activeRouteIdx); } + } else { + if (dx < -40) selectMiniRoute(activeRouteIdx + 1); + if (dx > 40) selectMiniRoute(activeRouteIdx - 1); + } + }); + + newMini.addEventListener('click', e => { + if (e.target.classList.contains('mini-arrow')) return; + hideMiniRouteSheet(); + openSheet('sheet-route'); + selectRoute(activeRouteIdx); + }); +} + +// ═══════════════════════════════════════════ +// TERRAIN LAYERS (Phase 5.4) +// ═══════════════════════════════════════════ + +const TERRAIN_BASE_URL = window.location.pathname.replace(/\/[^/]*$/, '') + '/terrain'; + +function toggleTerrainPopup() { + const popup = document.getElementById('terrain-popup'); + const btn = document.getElementById('terrain-toggle'); + if (!popup || !btn) return; + + const isVisible = popup.style.display !== 'none'; + popup.style.display = isVisible ? 'none' : 'block'; + btn.classList.toggle('active', !isVisible); + + // Close on outside click + if (!isVisible) { + setTimeout(() => { + document.addEventListener('click', closeTerrainOnOutside); + }, 10); + } else { + document.removeEventListener('click', closeTerrainOnOutside); + } +} + +function closeTerrainOnOutside(e) { + const popup = document.getElementById('terrain-popup'); + const btn = document.getElementById('terrain-toggle'); + if (!popup.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { + popup.style.display = 'none'; + btn.classList.remove('active'); + document.removeEventListener('click', closeTerrainOnOutside); + } +} + +function onTerrainCheckbox() { + const map = window._map; + if (!map) return; + + const hypsoChecked = document.getElementById('terrain-hypso-cb').checked; + const hillshadeChecked = document.getElementById('terrain-hillshade-cb').checked; + + // Save state + localStorage.setItem('terrain-hypso', hypsoChecked ? '1' : '0'); + localStorage.setItem('terrain-hillshade', hillshadeChecked ? '1' : '0'); + + // Update button active state + const btn = document.getElementById('terrain-toggle'); + btn.classList.toggle('active', hypsoChecked || hillshadeChecked); + + // Apply layers + applyTerrainLayer('terrain-hypso', TERRAIN_BASE_URL + '/hypso/{z}/{x}/{y}.png', hypsoChecked, 0.55, 5, 15); + applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15); +} + +function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) { + const map = window._map; + if (!map) return; + + const sourceId = id + '-source'; + + if (enabled) { + // Add source if not exists + if (!map.getSource(sourceId)) { + map.addSource(sourceId, { + type: 'raster', + tiles: [tileUrl], + tileSize: 256, + scheme: 'tms', + bounds: [35, 45, 55, 62], + minzoom: minzoom, + maxzoom: maxzoom + }); + } + // Add layer if not exists + if (!map.getLayer(id)) { + // Insert before first road/trail layer for correct z-order + const firstTrailLayer = map.getStyle().layers.find(l => + l.id.startsWith('trails-') || l.id.startsWith('poi-') + ); + map.addLayer({ + id: id, + type: 'raster', + source: sourceId, + paint: { + 'raster-opacity': opacity + }, + minzoom: minzoom, + maxzoom: maxzoom + }, firstTrailLayer ? firstTrailLayer.id : undefined); + } + } else { + // Remove layer and source + if (map.getLayer(id)) map.removeLayer(id); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } +} + +function updateHillshadeAvailability() { + const map = window._map; + if (!map) return; + + const zoom = map.getZoom(); + const cb = document.getElementById('terrain-hillshade-cb'); + const hint = document.getElementById('terrain-hillshade-hint'); + const label = cb ? cb.closest('.terrain-checkbox') : null; + + if (zoom < 10) { + if (cb) cb.disabled = true; + if (label) label.classList.add('disabled'); + if (hint) hint.style.display = 'inline'; + } else { + if (cb) cb.disabled = false; + if (label) label.classList.remove('disabled'); + if (hint) hint.style.display = 'none'; + } +} + +function restoreTerrainState() { + const hypso = localStorage.getItem('terrain-hypso') === '1'; + const hillshade = localStorage.getItem('terrain-hillshade') === '1'; + + const hypsoCb = document.getElementById('terrain-hypso-cb'); + const hillshadeCb = document.getElementById('terrain-hillshade-cb'); + + if (hypsoCb) hypsoCb.checked = hypso; + if (hillshadeCb) hillshadeCb.checked = hillshade; + + if (hypso || hillshade) { + onTerrainCheckbox(); + } + + // Update button active state + const btn = document.getElementById('terrain-toggle'); + if (btn) btn.classList.toggle('active', hypso || hillshade); +} + +// Hook into map load and zoom changes +(function initTerrain() { + const map = window._map; + if (map) { + map.on('zoomend', updateHillshadeAvailability); + map.on('style.load', () => { + // Re-apply terrain after style change (theme switch) + setTimeout(restoreTerrainState, 100); + }); + // Initial state + updateHillshadeAvailability(); + restoreTerrainState(); + } else { + // Map not ready yet, wait + const interval = setInterval(() => { + if (window._map) { + clearInterval(interval); + window._map.on('zoomend', updateHillshadeAvailability); + window._map.on('style.load', () => { + setTimeout(restoreTerrainState, 100); + }); + updateHillshadeAvailability(); + restoreTerrainState(); + } + }, 500); + } +})(); diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..b96248c --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,363 @@ + + + + + + + + + Enduro Trails + + + + + + +
+ + +
+ + + + +
+ 0 км + + +
+ +
Тапни на карту чтобы добавить точку
+ + +
⚠️ База данных недоступна
+ + +
+ + + +
+ + + + +
+
+
+ +

Маршрут

+ + + + + + +
+
+
+ +
Тапни точку старта на карте
+
+
+
+ + +
+
+
+ +

Разведка

+ +
+
+

Тапни точку на карте — узнаешь сколько грунтовок рядом

+ +
+ + + +
+ +
+
+ + +
+
+
+ +

Красивый маршрут

+ +
+
+
Тапни точку старта на карте
+ + +
+
+ + + + +
+ +
+ + + +
+
+
+ + + + + +
+
+
+
Тип метки
+
+ +
+
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
Вариант 1
+
— км · —% грунт
+
+
+ + +
+ +
+
+ + + + + + + diff --git a/src/web/style-dark.json b/src/web/style-dark.json new file mode 100644 index 0000000..bc93668 --- /dev/null +++ b/src/web/style-dark.json @@ -0,0 +1,136 @@ +{ + "version": 8, + "name": "Enduro Trails Dark", + "metadata": {}, + "center": [37.6, 55.75], + "zoom": 7, + "bearing": 0, + "pitch": 0, + "sources": { + "trails-tiles": { + "type": "vector", + "tiles": ["/api/tiles/{z}/{x}/{y}.mvt"], + "minzoom": 5, + "maxzoom": 16 + }, + "osm-raster": { + "type": "raster", + "tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], + "tileSize": 256, + "attribution": "© OpenStreetMap contributors" + } + }, + "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "#1a1a2e" } + }, + { + "id": "osm-base", + "type": "raster", + "source": "osm-raster", + "paint": { + "raster-opacity": 1.0, + "raster-saturation": -0.6, + "raster-contrast": -0.1, + "raster-brightness-min": 0, + "raster-brightness-max": 0.35 + } + }, + { + "id": "trails-asphalt", + "type": "line", + "source": "trails-tiles", + "source-layer": "trails", + "minzoom": 6, + "filter": ["in", "highway", "primary", "secondary", "tertiary", "residential"], + "paint": { + "line-color": "#bbbbbb", + "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 10, 1, 14, 2], + "line-opacity": 0.0 + }, + "layout": { "line-cap": "round", "line-join": "round", "visibility": "none" } + }, + { + "id": "trails-track", + "type": "line", + "source": "trails-tiles", + "source-layer": "trails", + "minzoom": 6, + "filter": ["==", "highway", "track"], + "paint": { + "line-color": [ + "match", ["get", "tracktype"], + "grade1", "#FFE066", + "grade2", "#FFE066", + "grade3", "#FF6633", + "grade4", "#FF6633", + "grade5", "#FF6633", + "#FF6633" + ], + "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.5, 8, 1.2, 10, 2, 12, 3.5, 16, 6], + "line-opacity": 0.95 + }, + "layout": { "line-cap": "round", "line-join": "round" } + }, + { + "id": "trails-path-bridleway", + "type": "line", + "source": "trails-tiles", + "source-layer": "trails", + "minzoom": 8, + "filter": ["in", "highway", "path", "bridleway", "footway"], + "paint": { + "line-color": "#ff4444", + "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0.5, 10, 1.5, 12, 2, 16, 3], + "line-opacity": 0.9, + "line-dasharray": [3, 2] + }, + "layout": { "line-cap": "butt", "line-join": "round" } + }, + { + "id": "poi-circles", + "type": "circle", + "source": "trails-tiles", + "source-layer": "poi", + "paint": { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 8, 2, 12, 6, 16, 10], + "circle-color": [ + "match", ["get", "poi_type"], + "natural=peak", "#ff4d5a", + "natural=water", "#3399e6", + "tourism=viewpoint", "#33cc33", + "historic=ruins", "#b366d9", + "natural=cave_entrance", "#f09030", + "ford=yes", "#00b3e6", + "#999999" + ], + "circle-stroke-color": "#333333", + "circle-stroke-width": 1.5, + "circle-opacity": 0.9 + } + }, + { + "id": "poi-labels", + "type": "symbol", + "source": "trails-tiles", + "source-layer": "poi", + "minzoom": 11, + "layout": { + "text-field": ["get", "name"], + "text-font": ["Open Sans Regular"], + "text-size": 11, + "text-offset": [0, 1.2], + "text-anchor": "top", + "text-optional": true + }, + "paint": { + "text-color": "#e0e0e0", + "text-halo-color": "#1a1a2e", + "text-halo-width": 2 + } + } + ] +} diff --git a/src/web/style.json b/src/web/style.json new file mode 100644 index 0000000..922613a --- /dev/null +++ b/src/web/style.json @@ -0,0 +1,136 @@ +{ + "version": 8, + "name": "Enduro Trails Light", + "metadata": {}, + "center": [37.6, 55.75], + "zoom": 7, + "bearing": 0, + "pitch": 0, + "sources": { + "trails-tiles": { + "type": "vector", + "tiles": ["/api/tiles/{z}/{x}/{y}.mvt"], + "minzoom": 5, + "maxzoom": 16 + }, + "osm-raster": { + "type": "raster", + "tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], + "tileSize": 256, + "attribution": "© OpenStreetMap contributors" + } + }, + "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "#f0ede6" } + }, + { + "id": "osm-base", + "type": "raster", + "source": "osm-raster", + "paint": { + "raster-opacity": 1.0, + "raster-saturation": -0.4, + "raster-contrast": 0.25, + "raster-brightness-min": 0, + "raster-brightness-max": 0.9 + } + }, + { + "id": "trails-asphalt", + "type": "line", + "source": "trails-tiles", + "source-layer": "trails", + "minzoom": 6, + "filter": ["in", "highway", "primary", "secondary", "tertiary", "residential"], + "paint": { + "line-color": "#bbbbbb", + "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 10, 1, 14, 2], + "line-opacity": 0.0 + }, + "layout": { "line-cap": "round", "line-join": "round", "visibility": "none" } + }, + { + "id": "trails-track", + "type": "line", + "source": "trails-tiles", + "source-layer": "trails", + "minzoom": 6, + "filter": ["==", "highway", "track"], + "paint": { + "line-color": [ + "match", ["get", "tracktype"], + "grade1", "#FFD700", + "grade2", "#FFD700", + "grade3", "#FF4400", + "grade4", "#FF4400", + "grade5", "#FF4400", + "#FF4400" + ], + "line-width": ["interpolate", ["linear"], ["zoom"], 6, 0.5, 8, 1.2, 10, 2, 12, 3.5, 16, 6], + "line-opacity": 0.9 + }, + "layout": { "line-cap": "round", "line-join": "round" } + }, + { + "id": "trails-path-bridleway", + "type": "line", + "source": "trails-tiles", + "source-layer": "trails", + "minzoom": 8, + "filter": ["in", "highway", "path", "bridleway", "footway"], + "paint": { + "line-color": "#cc0000", + "line-width": ["interpolate", ["linear"], ["zoom"], 7, 0.5, 10, 1.5, 12, 2, 16, 3], + "line-opacity": 0.85, + "line-dasharray": [3, 2] + }, + "layout": { "line-cap": "butt", "line-join": "round" } + }, + { + "id": "poi-circles", + "type": "circle", + "source": "trails-tiles", + "source-layer": "poi", + "paint": { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 8, 2, 12, 6, 16, 10], + "circle-color": [ + "match", ["get", "poi_type"], + "natural=peak", "#e63946", + "natural=water", "#1d7fc4", + "tourism=viewpoint", "#2a9d2a", + "historic=ruins", "#9b59b6", + "natural=cave_entrance", "#e67e22", + "ford=yes", "#0099cc", + "#888888" + ], + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1.5, + "circle-opacity": 0.9 + } + }, + { + "id": "poi-labels", + "type": "symbol", + "source": "trails-tiles", + "source-layer": "poi", + "minzoom": 11, + "layout": { + "text-field": ["get", "name"], + "text-font": ["Open Sans Regular"], + "text-size": 11, + "text-offset": [0, 1.2], + "text-anchor": "top", + "text-optional": true + }, + "paint": { + "text-color": "#333333", + "text-halo-color": "#ffffff", + "text-halo-width": 1.5 + } + } + ] +}