Merge pull request 'feat: migrate prototype + project docs' (#1) from feature/migrate-prototype into main

This commit was merged in pull request #1.
This commit is contained in:
2026-05-15 16:14:08 +03:00
15 changed files with 5756 additions and 3 deletions

View File

@@ -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"]

View File

@@ -1,3 +1,75 @@
# enduro-trails
# Enduro Trails 🏍️
OSM-карта с фокусом на грунтовые дороги для построения эндуро-маршрутов.
## Что это
Обычные карты оптимизированы под автомобили — асфальт яркий, грунтовки не видны. Enduro Trails переворачивает логику: **грунтовки и тропы — главный слой**, асфальт — тусклый фон.
Приложение помогает:
- Найти грунтовые дороги в любом районе
- Построить маршрут с максимумом грунта и минимумом асфальта
- Оценить сложность (grade 1-5, покрытие, уклон)
- Найти живописные места (озёра, виды, руины, броды)
- Экспортировать маршрут в GPX для навигатора
## Демо
https://openclaw.mva154.duckdns.org/enduro/
## Фичи
- 🗺️ **Карта грунтовок** — MapLibre GL JS, кастомный стиль, тёмная/светлая тема
- 🛤️ **Маршрут** — до 5 альтернатив с разным балансом грунт/асфальт, промежуточные точки
-**Красивый маршрут** — замкнутый круг через живописные POI
- 🔗 **Связка** — соединить два трека грунтовками
- 🔍 **Разведка** — статистика грунтовок в радиусе 20/50/100 км
- 📏 **Линейка** — измерение расстояний на карте
- 📍 **Метки** — сохранение точек интереса
- 🏔️ **Рельеф** — гипсометрия + hillshade (SRTM 30м)
- 📊 **Статистика** — % грунта/асфальта, время, дистанция
- 📥 **GPX экспорт** — трек + waypoints для навигатора
- 🌙 **Тёмная тема** — авто (по закату), ручная, синхронизация карты и UI
## Стек
| Компонент | Технология |
|-----------|-----------|
| Frontend | MapLibre GL JS + vanilla JS |
| Backend | Python 3.12 + FastAPI + uvicorn |
| БД | SQLite + Spatialite (1.1M треков, 14K POI) |
| Роутинг | OSRM с кастомным эндуро-профилем |
| Тайлы | Self-hosted raster (terrain, hillshade, TRI) |
| Контейнеризация | Docker + Compose |
| CI | Gitea Actions |
## Регион
ЦФО + Чувашия (расширение по запросу)
## Быстрый старт
```bash
make dev # поднять локально (Docker Compose)
make test # запустить тесты
make lint # линтеры
make build # собрать Docker-образ
```
## Структура
```
src/api/ — FastAPI backend (маршруты, тайлы, поиск)
src/web/ — фронтенд (MapLibre, UI)
tests/ — тесты (unit, integration, e2e)
docs/ — документация, ADR, work-items
scripts/ — утилиты
migrations/ — миграции БД
.openclaw/ — system prompts агентов
```
## Лицензия
Данные: © OpenStreetMap contributors (ODbL)
Рельеф: NASA SRTM (Public Domain)
Карта эндуро-маршрутов с рельефом и навигацией

View File

@@ -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

View File

@@ -0,0 +1,122 @@
---
type: phase-brd
phase_id: pilot
title: "Enduro Trails — пилотный проект мультиагентной разработки"
version: 1
status: approved
created_at: 2026-05-15
authors:
- "agent:stream"
- "human:slava"
---
# BRD — Enduro Trails (пилотный проект)
## 1. Цель и метрика успеха
**Цель:** Создать веб-приложение для планирования эндуро-маршрутов с визуализацией грунтовых дорог, рельефа и навигацией — как пилотный проект мультиагентной системы разработки.
**Метрики успеха:**
- Рабочее приложение доступно по URL (https://openclaw.mva154.duckdns.org/enduro/)
- Покрытие региона: ЦФО + Чувашия (1.1M треков, 14K POI)
- Построение маршрута < 5 секунд
- Мобильный UI (PWA-ready)
- Агентный конвейер: фича от постановки до деплоя ≤ 4 часа
## 2. Стейкхолдеры
| Роль | Кто | Интерес |
|------|-----|---------|
| Заказчик / Owner | Слава | Использует для планирования поездок |
| Analyst | Стрим (OpenClaw) | BRD, ТЗ, координация |
| Разработка | Claude Code CLI агенты | Architect, Developer, Reviewer, Tester, Deployer |
## 3. Scope
### В скоупе
- Карта грунтовых дорог (MapLibre GL JS, кастомный стиль)
- Роутинг «Дикий путь» (OSRM, кастомный профиль enduro.lua)
- Альтернативные маршруты (до 5 вариантов)
- Промежуточные точки (до 8)
- Статистика покрытия (% грунт/асфальт по типам)
- «Красивый маршрут» (замкнутый круг через POI)
- «Связка» (соединить два трека)
- «Разведка» (грунтовки в радиусе)
- Рельеф (гипсометрия + hillshade, SRTM 30м)
- TRI (Terrain Ruggedness Index)
- Линейка, метки, GPX экспорт
- Тёмная/светлая тема (авто по SunCalc)
- Мобильный UI (bottom sheets, toolbar, touch)
- Поиск (Nominatim geocoding)
### Вне скоупа (v1)
- PWA офлайн режим
- GPS-трекинг в реальном времени
- Народные треки (Wikiloc, Komoot)
- Профиль высот на маршруте
- Мультирегион (вся Россия)
- Нативное мобильное приложение
## 4. Архитектура
### Компоненты
- **Frontend** — MapLibre GL JS, vanilla JS (ES modules), CSS custom properties
- **Backend API** — FastAPI (Python 3.12), uvicorn (4 workers)
- **Tile Server** — статические raster tiles через nginx
- **Vector Tiles** — MVT из SQLite (self-hosted, FastAPI)
- **Routing Engine** — OSRM с кастомным профилем `enduro.lua`
- **Database** — SQLite + Spatialite (431 MB)
- **Reverse Proxy** — nginx (`/enduro/` → контейнер)
### Инфраструктура
- Один сервер: mva154 (82.22.50.71)
- Docker Compose
- Gitea (git + CI)
- Plane (управление задачами)
### Данные
- OSM PBF (Geofabrik, ЦФО + Чувашия)
- SRTM 30м (NASA, public domain)
- OSRM граф (~5.2 GB)
## 5. Реализованные фазы
| Фаза | Описание | Статус | Дата |
|------|----------|--------|------|
| 1 | MVP: карта + MVT тайлы | ✅ | 02.05.2026 |
| 2 | Роутинг + базовый UI | ✅ | 03.05.2026 |
| 3 | Умный маршрут (альтернативы, статистика, GPX) | ✅ | 04.05.2026 |
| 4 | Продвинутый роутинг (красивый, связка, разведка) | ✅ | 04.05.2026 |
| 5 | Редизайн (тёмная тема, mobile UI, UX) | ✅ | 05-06.05.2026 |
| 5.4 | Рельеф (hillshade + гипсометрия + TRI) | ✅ | 12-14.05.2026 |
## 6. Бэклог
| Фаза | Описание | Приоритет |
|------|----------|-----------|
| 3.1 | Улучшение роутинга (шлагбаумы, тротуары, слой препятствий) | Высокий |
| 6 | SRTM продвинутый (профиль высот, «Горка») | Средний |
| 7 | PWA + офлайн | Средний |
| 8 | Народные треки | Низкий |
## 7. Ключевые решения
| Решение | Причина |
|---------|---------|
| MapLibre GL JS (не Leaflet) | WebGL, производительность, vector tiles |
| Vanilla JS (не React) | Простота, нет build step, быстрый старт |
| FastAPI (не Django) | Лёгкий, async, минимум зависимостей |
| SQLite/Spatialite (не PostGIS) | Портативность, zero-config, достаточно для 1 региона |
| OSRM (не GraphHopper) | Быстрый, проверенный, кастомный lua-профиль |
| Self-hosted MVT (не TileServer GL) | Меньше зависимостей, контроль над фильтрацией |
| Raster tiles для terrain (не Mapbox Terrain RGB) | Простота генерации, nginx отдаёт статику |
| Docker Compose | Один файл — весь стек |
## 8. Риски
| Риск | Митигация |
|------|-----------|
| OSRM граф большой (5.2 GB RAM) | Swap 6 GB настроен |
| SQLite не масштабируется | Миграция на PostGIS при необходимости |
| Один сервер — single point of failure | Бэкапы, Docker restart policy |
| SRTM 30м недостаточно для крутых склонов | Достаточно для ЦФО (равнина) |

View File

@@ -0,0 +1,40 @@
---
type: phase-plan
phase_id: pilot
title: "План пилотной фазы"
version: 1
status: active
---
# План пилотной фазы — Enduro Trails
## Текущий статус
Проект в стадии перехода от прототипа к управляемой мультиагентной разработке.
### Выполнено (прототип, 02-14.05.2026)
- Фазы 1-5.4 реализованы вручную (Стрим + Dev-агент)
- Все основные фичи работают
- Приложение доступно по URL
### В процессе (мультиагентная инфраструктура, 15.05.2026)
- ✅ Репо в Gitea с канонической структурой
- ✅ Claude Code CLI авторизован
- ✅ Service account claude-bot
- ✅ Branch protection
- ✅ CI pipeline
- ✅ System prompts агентов
- 🔄 Миграция прототипа в репо
### Следующие шаги
1. Merge миграции в main
2. Первая задача через полный агентный конвейер (Фаза 1 мультиагентного BRD)
3. Orchestrator MVP (Фаза 2 мультиагентного BRD)
## Приоритет фич (бэклог)
1. **F-07 + F-08** — Исключить шлагбаумы и тротуары из OSRM (пересборка графа)
2. **F-10** — Слой препятствий на карте
3. **F-09** — Больше альтернатив (penalized re-query)
4. Профиль высот на маршруте
5. PWA офлайн

56
scripts/download_srtm.sh Executable file
View File

@@ -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)"

1255
src/api/main.py Normal file

File diff suppressed because it is too large Load Diff

5
src/api/requirements.txt Normal file
View File

@@ -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

771
src/web/app.css Normal file
View File

@@ -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); }
}

2790
src/web/app.js Normal file

File diff suppressed because it is too large Load Diff

363
src/web/index.html Normal file
View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Enduro Trails</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.css">
<link rel="stylesheet" href="app.css">
</head>
<body class="theme-dark">
<!-- Map -->
<div id="map"></div>
<!-- Sheet backdrop -->
<div id="sheet-backdrop" onclick="closeAllSheets()"></div>
<!-- ── Ruler info ─────────────────────────── -->
<div id="ruler-info">
<span id="ruler-dist">0 км</span>
<button class="ruler-action-btn" onclick="exitRulerMode()" title="Завершить">✓ Завершить</button>
<button class="ruler-action-btn ruler-action-btn--danger" onclick="deleteRuler()" title="Удалить линейку"></button>
</div>
<!-- ── Ruler toast hint ───────────────────── -->
<div id="ruler-toast">Тапни на карту чтобы добавить точку</div>
<!-- ── No data warning ───────────────────── -->
<div id="no-data-warning">⚠️ База данных недоступна</div>
<!-- ── Map Buttons (right) ───────────────── -->
<div id="map-controls-r">
<button class="map-btn" id="btn-compass" onclick="toggleCompass()" title="Компас">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-2.12 6.36-6.36 2.12 2.12-6.36 6.36-2.12z"/></svg>
</button>
<button class="map-btn" onclick="locateMe()" title="Моё местоположение">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
</button>
<button class="map-btn" id="btn-theme" onclick="toggleTheme()" title="Переключить тему">
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
<svg id="theme-icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
</button>
</div>
<!-- ════════════════════════════════════════════
BOTTOM SHEETS
════════════════════════════════════════════ -->
<!-- ── Sheet: Маршрут ────────────────────── -->
<div class="bottom-sheet" id="sheet-route">
<div class="sheet-handle"></div>
<div class="sheet-header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z"/><path d="M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/><path d="M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z"/><path d="M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z"/><path d="M14 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-3"/></svg>
<h2>Маршрут</h2>
<!-- Скачать GPX -->
<button class="sheet-icon-btn" onclick="downloadGPX()" title="Скачать GPX">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<!-- Сброс -->
<button class="sheet-icon-btn danger" onclick="resetRouteFromSheet()" title="Сбросить маршрут">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
<!-- Свернуть -->
<button class="sheet-close" onclick="minimizeSheet('sheet-route')" title="Свернуть">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
<div class="sheet-body">
<div id="waypoints-list"></div>
<div id="route-status" class="text-muted">Тапни точку старта на карте</div>
<div id="route-cards"></div>
</div>
</div>
<!-- ── Sheet: Разведка ───────────────────── -->
<div class="bottom-sheet" id="sheet-recon">
<div class="sheet-handle"></div>
<div class="sheet-header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M9 6.8a6 6 0 0 1 9 5.2c0 .47 0 1.17-.02 2"/></svg>
<h2>Разведка</h2>
<button class="sheet-close" onclick="minimizeSheet('sheet-recon')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="sheet-body">
<p class="sheet-hint" id="recon-hint">Тапни точку на карте — узнаешь сколько грунтовок рядом</p>
<div class="section-label">РАДИУС</div>
<div class="seg-control">
<button class="seg-btn active" data-km="20" onclick="setReconRadius(20)">20 км</button>
<button class="seg-btn" data-km="50" onclick="setReconRadius(50)">50 км</button>
<button class="seg-btn" data-km="100" onclick="setReconRadius(100)">100 км</button>
</div>
<div id="recon-results" style="display:none">
<div class="section-label">ГРУНТОВКИ</div>
<div class="recon-grid">
<div class="recon-stat">
<div class="rs-value" id="r-total-km"></div>
<div class="rs-label">км всего</div>
</div>
<div class="recon-stat">
<div class="rs-value gold" id="r-lev12-km"></div>
<div class="rs-label">км Lev 1-2</div>
</div>
<div class="recon-stat">
<div class="rs-value red" id="r-lev345-km"></div>
<div class="rs-label">км Lev 3-5</div>
</div>
<div class="recon-stat">
<div class="rs-value" id="r-path-km"></div>
<div class="rs-label">км тропы</div>
</div>
</div>
<div class="section-label">ИНТЕРЕСНОЕ</div>
<div id="r-poi-list"></div>
</div>
</div>
</div>
<!-- ── Sheet: Красивый маршрут ───────────── -->
<div class="bottom-sheet" id="sheet-scenic">
<div class="sheet-handle"></div>
<div class="sheet-header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/></svg>
<h2>Красивый маршрут</h2>
<button class="sheet-close" onclick="minimizeSheet('sheet-scenic')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="sheet-body">
<div id="scenic-status" class="text-muted">Тапни точку старта на карте</div>
<div class="section-label mt-8">ДИСТАНЦИЯ</div>
<div class="dist-row">
<div class="seg-control" style="flex:1">
<button class="seg-btn" data-km="50" onclick="setScenicKm(50)">50</button>
<button class="seg-btn active" data-km="100" onclick="setScenicKm(100)">100</button>
<button class="seg-btn" data-km="150" onclick="setScenicKm(150)">150</button>
<button class="seg-btn" data-km="200" onclick="setScenicKm(200)">200</button>
</div>
<input type="number" id="scenic-custom-km" class="dist-custom" placeholder="км" min="20" max="500" onchange="setScenicKm(+this.value||100)">
</div>
<button class="btn-primary" id="btn-build-scenic" onclick="buildScenicRoute()" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
Построить маршрут
</button>
<div id="scenic-cards" class="mt-8"></div>
</div>
</div>
<!-- ── Sheet: Связка ─────────────────────── -->
<div class="bottom-sheet" id="sheet-link">
<div class="sheet-handle"></div>
<div class="sheet-header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<h2>Связка</h2>
<button class="sheet-close" onclick="minimizeSheet('sheet-link')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="sheet-body">
<p class="sheet-hint" style="text-align:left;padding-top:0">Соедини два трека — найду оптимальную грунтовую связку</p>
<div class="link-points">
<div class="link-pt empty" id="link-pt-1">
<div class="link-pt-num">1</div>
<div class="link-pt-label">Конец первого трека</div>
</div>
<div class="link-pt empty" id="link-pt-2">
<div class="link-pt-num">2</div>
<div class="link-pt-label">Начало второго трека</div>
</div>
</div>
<div id="link-status" class="text-muted">Тапни первую точку на карте</div>
<div id="link-cards"></div>
</div>
</div>
<!-- ── Marker type dialog ─────────────────── -->
<div id="marker-dialog">
<div class="marker-dialog-inner">
<div class="sheet-handle"></div>
<div style="padding:8px 0 4px;font-size:13px;font-weight:700;color:var(--text)">Тип метки</div>
<div class="marker-type-grid" id="marker-type-grid"></div>
<button onclick="closeMarkerDialog()" style="width:100%;height:44px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;color:var(--text2);font-size:14px;font-weight:600;cursor:pointer;margin-top:4px">Отмена</button>
</div>
</div>
<!-- ════════════════════════════════════════════
BOTTOM TOOLBAR
════════════════════════════════════════════ -->
<nav id="toolbar">
<button class="tb-btn" id="tb-route" onclick="toggleRouteMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h5l2 9h10l-1.5 7H6.5"/><circle cx="10" cy="20" r="1"/><circle cx="18" cy="20" r="1"/></svg>
<span>Маршрут</span>
</button>
<button class="tb-btn" id="tb-link" onclick="toggleLinkMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<span>Связка</span>
</button>
<button class="tb-btn" id="tb-scenic" onclick="toggleScenicMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
<span>Красивый</span>
</button>
<button class="tb-btn" id="tb-recon" onclick="toggleReconMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"/><path d="M5 19.5C5.5 18 6 15 6 12c0-.7.12-1.37.34-2"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M9 6.8a6 6 0 0 1 9 5.2c0 .47 0 1.17-.02 2"/></svg>
<span>Разведка</span>
</button>
<button class="tb-btn" id="tb-ruler" onclick="toggleRuler()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/></svg>
<span>Линейка</span>
</button>
<button class="tb-btn" id="tb-marker" onclick="toggleMarkerMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
<span>Метка</span>
</button>
</nav>
<!-- Mini route sheet -->
<div id="sheet-route-mini">
<div class="mini-handle" id="mini-route-handle"></div>
<div class="mini-route-info">
<!-- Onboarding panel (shown before both waypoints are set) -->
<div id="mini-onboard" style="display:none; align-items:center; gap:8px; width:100%;">
<div id="mini-onboard-pin"></div>
<span id="mini-onboard-hint" style="flex:1; font-size:13px; color:var(--text2);"></span>
<button id="mini-onboard-search-btn" style="background:none;border:none;padding:4px 8px;cursor:pointer;color:var(--text2);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</button>
<!-- Кнопка отмены — показывается только в режиме добавления точки -->
<button id="mini-onboard-cancel-btn" style="display:none;background:none;border:none;padding:4px 8px;cursor:pointer;color:var(--text3);font-size:20px;line-height:1;" onclick="cancelAddWaypoint()" title="Отмена"></button>
</div>
<!-- Inline search panel for onboarding -->
<div id="mini-onboard-search-panel" style="display:none; position:absolute; bottom:100%; left:0; right:0; background:var(--surface); border:1px solid var(--border); border-radius:8px 8px 0 0; padding:8px; z-index:300;">
<input id="mini-onboard-search-input" type="text" placeholder="Поиск места..." autocomplete="off" autocorrect="off"
style="width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid var(--border); border-radius:6px; background:var(--surface2); color:var(--text); font-size:14px;">
<div id="mini-onboard-search-results" style="max-height:200px; overflow-y:auto; margin-top:4px;"></div>
</div>
<!-- Moto wheel loading indicator -->
<svg id="mini-wheel" class="moto-wheel" width="22" height="22" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Кноблинг: 36 зубцов по внешнему краю -->
<rect x="92.50" y="47.50" width="3" height="5" fill="currentColor" transform="rotate(0,94.00,50.00)"/>
<rect x="91.83" y="55.14" width="3" height="5" fill="currentColor" transform="rotate(10,93.33,57.64)"/>
<rect x="89.85" y="62.55" width="3" height="5" fill="currentColor" transform="rotate(20,91.35,65.05)"/>
<rect x="86.61" y="69.50" width="3" height="5" fill="currentColor" transform="rotate(30,88.11,72.00)"/>
<rect x="82.21" y="75.78" width="3" height="5" fill="currentColor" transform="rotate(40,83.71,78.28)"/>
<rect x="76.78" y="81.21" width="3" height="5" fill="currentColor" transform="rotate(50,78.28,83.71)"/>
<rect x="70.50" y="85.61" width="3" height="5" fill="currentColor" transform="rotate(60,72.00,88.11)"/>
<rect x="63.55" y="88.85" width="3" height="5" fill="currentColor" transform="rotate(70,65.05,91.35)"/>
<rect x="56.14" y="90.83" width="3" height="5" fill="currentColor" transform="rotate(80,57.64,93.33)"/>
<rect x="48.50" y="91.50" width="3" height="5" fill="currentColor" transform="rotate(90,50.00,94.00)"/>
<rect x="40.86" y="90.83" width="3" height="5" fill="currentColor" transform="rotate(100,42.36,93.33)"/>
<rect x="33.45" y="88.85" width="3" height="5" fill="currentColor" transform="rotate(110,34.95,91.35)"/>
<rect x="26.50" y="85.61" width="3" height="5" fill="currentColor" transform="rotate(120,28.00,88.11)"/>
<rect x="20.22" y="81.21" width="3" height="5" fill="currentColor" transform="rotate(130,21.72,83.71)"/>
<rect x="14.79" y="75.78" width="3" height="5" fill="currentColor" transform="rotate(140,16.29,78.28)"/>
<rect x="10.39" y="69.50" width="3" height="5" fill="currentColor" transform="rotate(150,11.89,72.00)"/>
<rect x="7.15" y="62.55" width="3" height="5" fill="currentColor" transform="rotate(160,8.65,65.05)"/>
<rect x="5.17" y="55.14" width="3" height="5" fill="currentColor" transform="rotate(170,6.67,57.64)"/>
<rect x="4.50" y="47.50" width="3" height="5" fill="currentColor" transform="rotate(180,6.00,50.00)"/>
<rect x="5.17" y="39.86" width="3" height="5" fill="currentColor" transform="rotate(190,6.67,42.36)"/>
<rect x="7.15" y="32.45" width="3" height="5" fill="currentColor" transform="rotate(200,8.65,34.95)"/>
<rect x="10.39" y="25.50" width="3" height="5" fill="currentColor" transform="rotate(210,11.89,28.00)"/>
<rect x="14.79" y="19.22" width="3" height="5" fill="currentColor" transform="rotate(220,16.29,21.72)"/>
<rect x="20.22" y="13.79" width="3" height="5" fill="currentColor" transform="rotate(230,21.72,16.29)"/>
<rect x="26.50" y="9.39" width="3" height="5" fill="currentColor" transform="rotate(240,28.00,11.89)"/>
<rect x="33.45" y="6.15" width="3" height="5" fill="currentColor" transform="rotate(250,34.95,8.65)"/>
<rect x="40.86" y="4.17" width="3" height="5" fill="currentColor" transform="rotate(260,42.36,6.67)"/>
<rect x="48.50" y="3.50" width="3" height="5" fill="currentColor" transform="rotate(270,50.00,6.00)"/>
<rect x="56.14" y="4.17" width="3" height="5" fill="currentColor" transform="rotate(280,57.64,6.67)"/>
<rect x="63.55" y="6.15" width="3" height="5" fill="currentColor" transform="rotate(290,65.05,8.65)"/>
<rect x="70.50" y="9.39" width="3" height="5" fill="currentColor" transform="rotate(300,72.00,11.89)"/>
<rect x="76.78" y="13.79" width="3" height="5" fill="currentColor" transform="rotate(310,78.28,16.29)"/>
<rect x="82.21" y="19.22" width="3" height="5" fill="currentColor" transform="rotate(320,83.71,21.72)"/>
<rect x="86.61" y="25.50" width="3" height="5" fill="currentColor" transform="rotate(330,88.11,28.00)"/>
<rect x="89.85" y="32.45" width="3" height="5" fill="currentColor" transform="rotate(340,91.35,34.95)"/>
<rect x="91.83" y="39.86" width="3" height="5" fill="currentColor" transform="rotate(350,93.33,42.36)"/>
<!-- Шина -->
<circle cx="50" cy="50" r="45" fill="currentColor" opacity="0.1"/>
<circle cx="50" cy="50" r="42" fill="none" stroke="currentColor" stroke-width="6"/>
<!-- Обод: двойное кольцо -->
<circle cx="50" cy="50" r="34" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="50" cy="50" r="31" fill="none" stroke="currentColor" stroke-width="1"/>
<!-- Спицы: 32 штуки -->
<line x1="57.00" y1="50.00" x2="80.00" y2="50.00" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="56.87" y1="51.37" x2="79.42" y2="55.85" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="56.47" y1="52.68" x2="77.72" y2="61.48" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="55.82" y1="53.89" x2="74.94" y2="66.67" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="54.95" y1="54.95" x2="71.21" y2="71.21" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="53.89" y1="55.82" x2="66.67" y2="74.94" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="52.68" y1="56.47" x2="61.48" y2="77.72" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="51.37" y1="56.87" x2="55.85" y2="79.42" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="50.00" y1="57.00" x2="50.00" y2="80.00" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="48.63" y1="56.87" x2="44.15" y2="79.42" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="47.32" y1="56.47" x2="38.52" y2="77.72" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="46.11" y1="55.82" x2="33.33" y2="74.94" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="45.05" y1="54.95" x2="28.79" y2="71.21" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="44.18" y1="53.89" x2="25.06" y2="66.67" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="43.53" y1="52.68" x2="22.28" y2="61.48" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="43.13" y1="51.37" x2="20.58" y2="55.85" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="43.00" y1="50.00" x2="20.00" y2="50.00" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="43.13" y1="48.63" x2="20.58" y2="44.15" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="43.53" y1="47.32" x2="22.28" y2="38.52" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="44.18" y1="46.11" x2="25.06" y2="33.33" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="45.05" y1="45.05" x2="28.79" y2="28.79" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="46.11" y1="44.18" x2="33.33" y2="25.06" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="47.32" y1="43.53" x2="38.52" y2="22.28" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="48.63" y1="43.13" x2="44.15" y2="20.58" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="50.00" y1="43.00" x2="50.00" y2="20.00" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="51.37" y1="43.13" x2="55.85" y2="20.58" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="52.68" y1="43.53" x2="61.48" y2="22.28" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="53.89" y1="44.18" x2="66.67" y2="25.06" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="54.95" y1="45.05" x2="71.21" y2="28.79" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="55.82" y1="46.11" x2="74.94" y2="33.33" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="56.47" y1="47.32" x2="77.72" y2="38.52" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<line x1="56.87" y1="48.63" x2="79.42" y2="44.15" stroke="currentColor" stroke-width="0.8" opacity="0.7"/>
<!-- Ступица -->
<circle cx="50" cy="50" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="50" cy="50" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<!-- Болты ступицы: 8 штук -->
<circle cx="62.00" cy="50.00" r="1.5" fill="currentColor"/>
<circle cx="58.49" cy="58.49" r="1.5" fill="currentColor"/>
<circle cx="50.00" cy="62.00" r="1.5" fill="currentColor"/>
<circle cx="41.51" cy="58.49" r="1.5" fill="currentColor"/>
<circle cx="38.00" cy="50.00" r="1.5" fill="currentColor"/>
<circle cx="41.51" cy="41.51" r="1.5" fill="currentColor"/>
<circle cx="50.00" cy="38.00" r="1.5" fill="currentColor"/>
<circle cx="58.49" cy="41.51" r="1.5" fill="currentColor"/>
<!-- Центр -->
<circle cx="50" cy="50" r="3" fill="currentColor"/>
</svg>
<div class="mini-route-dot" id="mini-dot"></div>
<div class="mini-route-text">
<div class="mini-route-label" id="mini-label">Вариант 1</div>
<div class="mini-route-stats" id="mini-stats">— км · —% грунт</div>
</div>
<div class="mini-route-arrows">
<span class="mini-arrow" id="mini-prev"></span>
<span class="mini-arrow" id="mini-next"></span>
</div>
<button class="mini-add-btn" id="mini-add-btn" onclick="miniAddWaypoint()" title="Добавить точку">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
<script src="app.js"></script>
</body>
</html>

136
src/web/style-dark.json Normal file
View File

@@ -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
}
}
]
}

136
src/web/style.json Normal file
View File

@@ -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
}
}
]
}