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:
@@ -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"]
|
||||
|
||||
76
README.md
76
README.md
@@ -1,3 +1,75 @@
|
||||
# enduro-trails
|
||||
# Enduro Trails 🏍️
|
||||
|
||||
OSM-карта с фокусом на грунтовые дороги для построения эндуро-маршрутов.
|
||||
|
||||
## Что это
|
||||
|
||||
Обычные карты оптимизированы под автомобили — асфальт яркий, грунтовки не видны. Enduro Trails переворачивает логику: **грунтовки и тропы — главный слой**, асфальт — тусклый фон.
|
||||
|
||||
Приложение помогает:
|
||||
- Найти грунтовые дороги в любом районе
|
||||
- Построить маршрут с максимумом грунта и минимумом асфальта
|
||||
- Оценить сложность (grade 1-5, покрытие, уклон)
|
||||
- Найти живописные места (озёра, виды, руины, броды)
|
||||
- Экспортировать маршрут в GPX для навигатора
|
||||
|
||||
## Демо
|
||||
|
||||
https://openclaw.mva154.duckdns.org/enduro/
|
||||
|
||||
## Фичи
|
||||
|
||||
- 🗺️ **Карта грунтовок** — MapLibre GL JS, кастомный стиль, тёмная/светлая тема
|
||||
- 🛤️ **Маршрут** — до 5 альтернатив с разным балансом грунт/асфальт, промежуточные точки
|
||||
- ✨ **Красивый маршрут** — замкнутый круг через живописные POI
|
||||
- 🔗 **Связка** — соединить два трека грунтовками
|
||||
- 🔍 **Разведка** — статистика грунтовок в радиусе 20/50/100 км
|
||||
- 📏 **Линейка** — измерение расстояний на карте
|
||||
- 📍 **Метки** — сохранение точек интереса
|
||||
- 🏔️ **Рельеф** — гипсометрия + hillshade (SRTM 30м)
|
||||
- 📊 **Статистика** — % грунта/асфальта, время, дистанция
|
||||
- 📥 **GPX экспорт** — трек + waypoints для навигатора
|
||||
- 🌙 **Тёмная тема** — авто (по закату), ручная, синхронизация карты и UI
|
||||
|
||||
## Стек
|
||||
|
||||
| Компонент | Технология |
|
||||
|-----------|-----------|
|
||||
| Frontend | MapLibre GL JS + vanilla JS |
|
||||
| Backend | Python 3.12 + FastAPI + uvicorn |
|
||||
| БД | SQLite + Spatialite (1.1M треков, 14K POI) |
|
||||
| Роутинг | OSRM с кастомным эндуро-профилем |
|
||||
| Тайлы | Self-hosted raster (terrain, hillshade, TRI) |
|
||||
| Контейнеризация | Docker + Compose |
|
||||
| CI | Gitea Actions |
|
||||
|
||||
## Регион
|
||||
|
||||
ЦФО + Чувашия (расширение по запросу)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
make dev # поднять локально (Docker Compose)
|
||||
make test # запустить тесты
|
||||
make lint # линтеры
|
||||
make build # собрать Docker-образ
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
src/api/ — FastAPI backend (маршруты, тайлы, поиск)
|
||||
src/web/ — фронтенд (MapLibre, UI)
|
||||
tests/ — тесты (unit, integration, e2e)
|
||||
docs/ — документация, ADR, work-items
|
||||
scripts/ — утилиты
|
||||
migrations/ — миграции БД
|
||||
.openclaw/ — system prompts агентов
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Данные: © OpenStreetMap contributors (ODbL)
|
||||
Рельеф: NASA SRTM (Public Domain)
|
||||
|
||||
Карта эндуро-маршрутов с рельефом и навигацией
|
||||
@@ -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
|
||||
|
||||
122
docs/phases/pilot/00-phase-brd.md
Normal file
122
docs/phases/pilot/00-phase-brd.md
Normal 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м недостаточно для крутых склонов | Достаточно для ЦФО (равнина) |
|
||||
40
docs/phases/pilot/01-phase-plan.md
Normal file
40
docs/phases/pilot/01-phase-plan.md
Normal 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
56
scripts/download_srtm.sh
Executable 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
1255
src/api/main.py
Normal file
File diff suppressed because it is too large
Load Diff
5
src/api/requirements.txt
Normal file
5
src/api/requirements.txt
Normal 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
771
src/web/app.css
Normal 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
2790
src/web/app.js
Normal file
File diff suppressed because it is too large
Load Diff
363
src/web/index.html
Normal file
363
src/web/index.html
Normal 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
136
src/web/style-dark.json
Normal 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
136
src/web/style.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user