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 = ``;
+ } else {
+ el.innerHTML = ``;
+ }
+
+ 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 ``;
+ }
+
+ return ``;
+}
+
+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 `
+
+
+
${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 ``;
+ }).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 = `
+
+ ${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 `
+
+
${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
+
Конец первого трека
+
+
+
2
+
Начало второго трека
+
+
+
Тапни первую точку на карте
+
+
+
+
+
+
+
+
+
Тип метки
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Вариант 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
+ }
+ }
+ ]
+}