feat: migrate prototype to canonical structure
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

- Move app.py to src/api/main.py (STATIC_DIR from env)
- Move static/ to src/web/
- Update Dockerfile and docker-compose.yml
- Add download_srtm.sh script
- Clean up debug/deploy scripts

Refs: multi-agent F0-3
This commit is contained in:
2026-05-15 13:45:42 +03:00
parent 5d7fda44bb
commit eda66eeb6c
12 changed files with 5520 additions and 1 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

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

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