diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css index a69f60c..5ca1033 100644 --- a/tasks/enduro-trails/prototype/static/app.css +++ b/tasks/enduro-trails/prototype/static/app.css @@ -282,4 +282,265 @@ html, body { -webkit-tap-highlight-color: transparent; animation: cardFadeIn 0.2s ease-out both; } -.route-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var \ No newline at end of file +.route-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.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 { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); } +.rc-km { font-size: 14px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; } +.rc-time { font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; } +.rc-bar { height: 5px; border-radius: 3px; background: var(--surface3); overflow: hidden; margin-bottom: 8px; display: flex; } +.rc-bar-dirt { background: var(--gold); height: 100%; transition: width 0.4s; } +.rc-bar-asphalt { background: var(--text3); height: 100%; flex: 1; } +.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: 12px; right: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 14px; + 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: 8px; } + +/* ── Waypoint Markers ─────────────────────────── */ +.route-waypoint-marker { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: #fff; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.8); } +.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; } + +/* ═══════════════════════════════════════════════════ + 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; } + #search-bar { left: 84px; right: 12px; max-width: 400px; } + #map-controls-r { right: 12px; bottom: 12px; } + #sheet-backdrop { display: none; } + #ruler-info { left: 84px; } + #search-results { left: 84px; right: 12px; max-width: 400px; } +} + +/* ═══════════════════════════════════════════════════ + 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 { animation: markerPopIn 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28) both; } + +/* ── 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); } +.search-result-name { font-size: 14px; font-weight: 500; color: var(--text); } +.search-result-detail { font-size: 12px; color: var(--text2); margin-top: 2px; } diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index fe9450a..6cfc1d9 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -1,16 +1,158 @@ -// ─── Общее: деактивация всех режимов ──────────────────────────────────────────── +// ═══════════════════════════════════════════════════════════════════ +// Enduro Trails — Phase 5 Redesign +// Theme system (auto/light/dark + SunCalc), skeleton, swipe, animations +// ═══════════════════════════════════════════════════════════════════ -function deactivateAllModes() { - if (routeMode) { routeMode = false; document.getElementById('tb-route').classList.remove('active'); closeSheet('sheet-route'); clearRoute(); } - if (rulerMode) toggleRuler(); - if (markerMode) toggleMarkerMode(); - if (typeof reconMode !== 'undefined' && reconMode) toggleReconMode(); - if (typeof linkMode !== 'undefined' && linkMode) toggleLinkMode(); - if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); - if (window._map) window._map.getCanvas().style.cursor = ''; +// ─── Theme System ────────────────────────────────────────────────── +let themeMode = localStorage.getItem('enduro-theme-mode') || 'auto'; // 'auto' | 'light' | 'dark' +let userLat = null; +let userLon = null; +let themeAutoInterval = null; + +function isDarkTheme() { + return document.body.classList.contains('theme-dark'); } -// ─── Утилиты ────────────────────────────────────────────────────────────────── +function applyTheme() { + if (themeMode === 'light') { + document.body.className = 'theme-light'; + } else if (themeMode === 'dark') { + document.body.className = 'theme-dark'; + } else { + // auto: use SunCalc + applyAutoTheme(); + } + updateThemeButtonIcon(); + switchMapStyle(); +} + +function applyAutoTheme() { + if (themeMode !== 'auto') return; + const now = new Date(); + const lat = userLat || 55.75; + const lon = userLon || 37.62; + let isDay = true; + try { + if (typeof SunCalc !== 'undefined') { + const times = SunCalc.getTimes(now, lat, lon); + isDay = now >= times.sunrise && now < times.sunset; + } else { + // Fallback: assume day if 6am-8pm + const h = now.getHours(); + isDay = h >= 6 && h < 20; + } + } catch(e) { + const h = now.getHours(); + isDay = h >= 6 && h < 20; + } + document.body.className = isDay ? 'theme-light' : 'theme-dark'; + updateThemeButtonIcon(); +} + +function toggleTheme() { + // Cycle: auto → light → dark → auto + if (themeMode === 'auto') themeMode = 'light'; + else if (themeMode === 'light') themeMode = 'dark'; + else themeMode = 'auto'; + + localStorage.setItem('enduro-theme-mode', themeMode); + applyTheme(); +} + +function updateThemeButtonIcon() { + const sunIcon = document.getElementById('theme-icon-sun'); + const moonIcon = document.getElementById('theme-icon-moon'); + const label = document.getElementById('theme-label'); + if (!sunIcon || !moonIcon) return; + + const dark = isDarkTheme(); + + if (themeMode === 'auto') { + // Dynamic icon based on actual theme + sunIcon.style.display = dark ? 'none' : 'block'; + moonIcon.style.display = dark ? 'block' : 'none'; + if (label) label.textContent = 'Авто'; + } else if (themeMode === 'light') { + sunIcon.style.display = 'block'; + moonIcon.style.display = 'none'; + if (label) label.textContent = 'День'; + } else { + sunIcon.style.display = 'none'; + moonIcon.style.display = 'block'; + if (label) label.textContent = 'Ночь'; + } +} + +function switchMapStyle() { + const map = window._map; + if (!map) return; + const dark = isDarkTheme(); + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const styleUrl = dark ? basePath + '/style.json' : basePath + '/style-light.json'; + + // Check if style-light.json exists — if not, keep current + fetch(styleUrl, { method: 'HEAD' }).then(r => { + if (r.ok) { + map.setStyle(styleUrl); + } else { + // No light style available, keep dark + if (!dark) { + // Try inline light style override or just skip + console.log('Light map style not available, keeping dark'); + } + } + }).catch(() => { + // Network error, don't switch + }); +} + +// Re-add layers after style change +function onMapStyleLoad() { + const map = window._map; + if (!map) return; + // Re-add any active route layers, markers, etc. + rebuildMapOverlays(); +} + +function rebuildMapOverlays() { + // Re-apply recon circle if active + if (reconMode && reconCenter) { + doRecon(reconCenter[0], reconCenter[1]); + } + // Re-draw route if active + if (routeMode && routeResults.length > 0) { + const savedResults = [...routeResults]; + const savedIdx = activeRouteIdx; + routeResults = []; + drawRouteResults(savedResults, savedIdx); + } + // Re-draw scenic routes + if (scenicMode && scenicRoutes.length > 0) { + const savedRoutes = [...scenicRoutes]; + scenicRoutes = []; + drawScenicRoutes(savedRoutes, activeScenicIdx); + } + // Re-draw link routes + if (linkMode && linkPoints.length >= 2) { + buildLinkRoute(); + } + // Re-draw ruler + if (rulerMode && rulerPoints.length > 0) { + const pts = [...rulerPoints]; + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + if (map.getSource('ruler')) map.removeSource('ruler'); + if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); + pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); + } + // Re-render named markers + renderMarkers(); +} + +// ─── Utilities ────────────────────────────────────────────────────── function formatDuration(seconds) { const totalMin = Math.round(seconds / 60); @@ -33,7 +175,117 @@ function formatDist(m) { return Math.round(m) + ' м'; } -// ─── Компас ─────────────────────────────────────────────────────────────────── +// ─── Sheet Management ────────────────────────────────────────────── + +function openSheet(id) { + const sheet = document.getElementById(id); + if (!sheet) return; + // Close all other sheets first + document.querySelectorAll('.bottom-sheet.open').forEach(s => { + if (s.id !== id) closeSheet(s.id); + }); + sheet.classList.add('open'); + const backdrop = document.getElementById('sheet-backdrop'); + backdrop.classList.add('visible'); +} + +function closeSheet(id) { + const sheet = document.getElementById(id); + if (!sheet) return; + sheet.classList.remove('open'); + sheet.style.transform = ''; + // Check if any sheets still open + const anyOpen = document.querySelector('.bottom-sheet.open'); + if (!anyOpen) { + document.getElementById('sheet-backdrop').classList.remove('visible'); + } +} + +function closeAllSheets() { + document.querySelectorAll('.bottom-sheet.open').forEach(s => { + s.classList.remove('open'); + s.style.transform = ''; + }); + document.getElementById('sheet-backdrop').classList.remove('visible'); +} + +// ─── Swipe-down to close sheets ──────────────────────────────────── + +function initSheetSwipe() { + document.querySelectorAll('.bottom-sheet').forEach(sheet => { + let startY = 0; + let isDragging = false; + + sheet.addEventListener('touchstart', (e) => { + const rect = sheet.getBoundingClientRect(); + const touchY = e.touches[0].clientY; + // Only initiate swipe from the handle area (top 50px of sheet) + if (touchY < rect.top + 50 || e.target.closest('.sheet-handle')) { + isDragging = true; + startY = touchY; + sheet.classList.add('swiping'); + } + }, { passive: true }); + + sheet.addEventListener('touchmove', (e) => { + if (!isDragging) return; + const dy = e.touches[0].clientY - startY; + if (dy > 0) { + sheet.style.transform = `translateY(${dy}px)`; + } + }, { passive: true }); + + sheet.addEventListener('touchend', (e) => { + if (!isDragging) return; + isDragging = false; + sheet.classList.remove('swiping'); + const dy = e.changedTouches[0].clientY - startY; + if (dy > 80) { + // Close the sheet and deactivate mode + const sheetId = sheet.id; + closeSheet(sheetId); + // Deactivate corresponding mode + if (sheetId === 'sheet-route' && routeMode) toggleRouteMode(); + else if (sheetId === 'sheet-recon' && reconMode) toggleReconMode(); + else if (sheetId === 'sheet-scenic' && scenicMode) toggleScenicMode(); + else if (sheetId === 'sheet-link' && linkMode) toggleLinkMode(); + } else { + sheet.style.transform = ''; + } + }, { passive: true }); + }); +} + +// ─── Skeleton Loading ────────────────────────────────────────────── + +function showSkeleton(containerId, count) { + const container = document.getElementById(containerId); + if (!container) return; + count = count || 2; + let html = ''; + for (let i = 0; i < count; i++) { + html += `
+
+
+
+
`; + } + container.innerHTML = html; +} + +// ─── Deactivate All Modes ────────────────────────────────────────── + +function deactivateAllModes() { + if (routeMode) { routeMode = false; document.getElementById('tb-route').classList.remove('active'); closeSheet('sheet-route'); clearRoute(); } + if (rulerMode) toggleRuler(); + if (markerMode) toggleMarkerMode(); + if (typeof reconMode !== 'undefined' && reconMode) toggleReconMode(); + if (typeof linkMode !== 'undefined' && linkMode) toggleLinkMode(); + if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); + if (window._map) window._map.getCanvas().style.cursor = ''; +} + +// ─── Компас ──────────────────────────────────────────────────────── let compassLocked = false; function toggleCompass() { @@ -45,19 +297,15 @@ function toggleCompass() { map.rotateTo(0, { duration: 300 }); map.dragRotate.disable(); map.touchZoomRotate.disableRotation(); - btn.textContent = '⬆️'; - btn.title = 'Север вверху (нажми для свободного вращения)'; btn.classList.add('active'); } else { map.dragRotate.enable(); map.touchZoomRotate.enableRotation(); - btn.textContent = '🧭'; - btn.title = 'Свободное вращение'; btn.classList.remove('active'); } } -// ─── Геолокация ─────────────────────────────────────────────────────────────── +// ─── Геолокация ──────────────────────────────────────────────────── let locationMarker = null; function locateMe() { @@ -65,13 +313,12 @@ function locateMe() { alert('Геолокация недоступна в этом браузере'); return; } - const btn = document.getElementById('btn-locate-x'); - btn.textContent = '⏳'; navigator.geolocation.getCurrentPosition( (pos) => { const { longitude, latitude } = pos.coords; const map = window._map; - btn.textContent = '🎯'; + userLat = latitude; + userLon = longitude; map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 }); if (locationMarker) { locationMarker.setLngLat([longitude, latitude]); @@ -83,23 +330,18 @@ function locateMe() { .setLngLat([longitude, latitude]) .addTo(map); } + // If in auto theme mode, recalculate with real coordinates + if (themeMode === 'auto') applyAutoTheme(); }, (err) => { - btn.textContent = '🎯'; alert('Не удалось определить местоположение: ' + err.message); }, { enableHighAccuracy: true, timeout: 10000 } ); } -// ─── Layer visibility state ─────────────────────────────────────────────────── -const layerState = { - tracks: true, - paths: true, - poi: true, - basemap: true, -}; - +// ─── Layer visibility state ──────────────────────────────────────── +const layerState = { tracks: true, paths: true, poi: true, basemap: true }; const layerGroups = { tracks: ['trails-track', 'trails-asphalt'], paths: ['trails-path-bridleway'], @@ -119,96 +361,84 @@ function toggleLayer(group) { }); } - -// ─── Роутинг — состояние ────────────────────────────────────────────────────── +// ─── Роутинг — состояние ─────────────────────────────────────────── const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; - let routeMode = false; -let routeWaypoints = []; // [{lon, lat}, ...] -let routeResults = []; // массив маршрутов из API +let routeWaypoints = []; +let routeResults = []; let activeRouteIdx = 0; -let waypointMarkers = []; // MapLibre маркеры точек -let addingWaypoint = false; // режим добавления промежуточной точки +let waypointMarkers = []; +let addingWaypoint = false; let buildDebounceTimer = null; function getBasePath() { return window.location.pathname.replace(/\/[^/]*$/, '') || ''; } -// ─── Режим маршрута ─────────────────────────────────────────────────────────── +// ─── Режим маршрута ──────────────────────────────────────────────── function toggleRouteMode() { routeMode = !routeMode; const btn = document.getElementById('tb-route'); - const panel = document.getElementById('route-panel'); if (routeMode) { deactivateAllModes(); routeMode = true; btn.classList.add('active'); - panel.style.display = 'block'; + openSheet('sheet-route'); clearRoute(); window._map.getCanvas().style.cursor = 'crosshair'; } else { btn.classList.remove('active'); - panel.style.display = 'none'; + closeSheet('sheet-route'); clearRoute(); window._map.getCanvas().style.cursor = ''; } } function clearRoute() { - // Убираем маркеры точек waypointMarkers.forEach(m => m.remove()); waypointMarkers = []; routeWaypoints = []; routeResults = []; activeRouteIdx = 0; addingWaypoint = false; - - // Убираем слои маршрутов const map = window._map; if (map) { for (let i = 0; i < 5; i++) { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); - } - if (map.getSource('route')) map.removeSource('route'); - for (let i = 0; i < 5; i++) { if (map.getSource('route-' + i)) map.removeSource('route-' + i); } } - - document.getElementById('route-status').textContent = 'Кликни точку старта'; + document.getElementById('route-status').textContent = 'Тапни точку старта на карте'; document.getElementById('route-actions').style.display = 'none'; document.getElementById('route-cards').innerHTML = ''; document.getElementById('waypoints-list').innerHTML = ''; - document.getElementById('btn-add-waypoint').style.display = ''; - if (routeMode && map) map.getCanvas().style.cursor = 'crosshair'; } -// ─── Добавление промежуточной точки ────────────────────────────────────────── -function startAddWaypoint() { +function addWaypointMode() { if (routeWaypoints.length >= 10) return; addingWaypoint = true; window._map.getCanvas().style.cursor = 'crosshair'; - document.getElementById('route-status').textContent = 'Кликни на карте для добавления точки'; + document.getElementById('route-status').textContent = 'Тапни на карте для добавления точки'; } -// ─── Маркеры точек ──────────────────────────────────────────────────────────── +// ─── Маркеры точек ───────────────────────────────────────────────── function createWaypointMarkerEl(index, total) { const el = document.createElement('div'); - el.className = 'route-waypoint-marker'; + el.className = 'route-waypoint-marker marker-anim'; let bg, text, color = '#fff'; if (index === 0) { - bg = '#00aa44'; text = 'A'; - el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + bg = '#2EA043'; text = 'A'; } else if (index === total - 1) { - bg = '#cc0000'; text = 'B'; - el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + bg = '#FF3B1F'; text = 'B'; } else { - text = String(index); - el.style.cssText = `width:18px;height:18px;background:#fff;border:2px solid #0066ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#0066ff;box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + bg = '#fff'; text = String(index); color = '#0066ff'; + el.style.cssText = `width:18px;height:18px;background:${bg};border:2px solid #0066ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + el.textContent = text; + return el; } + el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; el.textContent = text; return el; } @@ -222,8 +452,6 @@ function rebuildWaypointMarkers() { const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }) .setLngLat([wp.lon, wp.lat]) .addTo(map); - - // Захватываем индекс в замыкании (function(idx) { marker.on('dragend', () => { const lngLat = marker.getLngLat(); @@ -232,7 +460,6 @@ function rebuildWaypointMarkers() { debounceBuildRoute(); }); })(i); - waypointMarkers.push(marker); }); } @@ -240,59 +467,19 @@ function rebuildWaypointMarkers() { function renderWaypointsList() { const list = document.getElementById('waypoints-list'); if (!routeWaypoints.length) { list.innerHTML = ''; return; } - list.innerHTML = routeWaypoints.map((wp, i) => { let labelClass, labelText; if (i === 0) { labelClass = 'start'; labelText = 'A'; } else if (i === routeWaypoints.length - 1) { labelClass = 'end'; labelText = 'B'; } else { labelClass = 'mid'; labelText = String(i); } - - return `
- ${labelText} - ${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)} - + return `
+
+ ${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)} +
`; }).join(''); - - // Показываем/скрываем кнопку добавления - document.getElementById('btn-add-waypoint').style.display = - routeWaypoints.length >= 10 ? 'none' : ''; -} - -// ─── Drag-and-drop порядка точек ────────────────────────────────────────────── -let dragWpIdx = null; - -function onWpDragStart(e, idx) { - dragWpIdx = idx; - e.dataTransfer.effectAllowed = 'move'; -} - -function onWpDragOver(e, idx) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - document.querySelectorAll('.waypoint-row').forEach((r, i) => { - r.classList.toggle('drag-over', i === idx); - }); -} - -function onWpDragLeave(e) { - e.currentTarget.classList.remove('drag-over'); -} - -function onWpDrop(e, idx) { - e.preventDefault(); - document.querySelectorAll('.waypoint-row').forEach(r => r.classList.remove('drag-over')); - if (dragWpIdx === null || dragWpIdx === idx) return; - const moved = routeWaypoints.splice(dragWpIdx, 1)[0]; - routeWaypoints.splice(idx, 0, moved); - dragWpIdx = null; - rebuildWaypointMarkers(); - renderWaypointsList(); - if (routeWaypoints.length >= 2) debounceBuildRoute(); } function removeWaypoint(idx) { @@ -302,7 +489,6 @@ function removeWaypoint(idx) { if (routeWaypoints.length >= 2) { debounceBuildRoute(); } else { - // Убираем маршруты с карты const map = window._map; for (let i = 0; i < 5; i++) { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); @@ -314,13 +500,12 @@ function removeWaypoint(idx) { document.getElementById('route-actions').style.display = routeWaypoints.length >= 2 ? 'block' : 'none'; document.getElementById('route-status').textContent = - routeWaypoints.length === 0 ? 'Кликни точку старта' : - routeWaypoints.length === 1 ? 'Кликни точку финиша' : ''; + routeWaypoints.length === 0 ? 'Тапни точку старта на карте' : + routeWaypoints.length === 1 ? 'Тапни точку финиша' : ''; } } - -// ─── Построение маршрута ────────────────────────────────────────────────────── +// ─── Построение маршрута ─────────────────────────────────────────── function debounceBuildRoute() { clearTimeout(buildDebounceTimer); buildDebounceTimer = setTimeout(buildRoute, 300); @@ -330,11 +515,10 @@ async function buildRoute() { if (routeWaypoints.length < 2) return; const map = window._map; const basePath = getBasePath(); - + document.getElementById('route-status').textContent = '⏳ Строю маршрут...'; - const btn = document.getElementById('btn-build-route'); - if (btn) btn.textContent = '⏳ Строю...'; - + showSkeleton('route-cards', 3); + try { const resp = await fetch(basePath + '/api/route', { method: 'POST', @@ -345,112 +529,94 @@ async function buildRoute() { const data = await resp.json(); routeResults = data.routes || []; if (!routeResults.length) throw new Error('Маршрут не найден'); - - // Убираем старые слои - for (let i = 0; i < 5; i++) { - if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); - if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); - if (map.getSource('route-' + i)) map.removeSource('route-' + i); - } - - // Рисуем все маршруты - routeResults.forEach((route, i) => { - const color = ROUTE_COLORS[i] || '#888888'; - const isActive = i === activeRouteIdx; - map.addSource('route-' + i, { - type: 'geojson', - data: { type: 'Feature', geometry: route.geometry, properties: {} } - }); - // Обводка (белая) для активного - map.addLayer({ - id: 'route-line-' + i + '-outline', - type: 'line', - source: 'route-' + i, - paint: { - 'line-color': '#ffffff', - 'line-width': isActive ? 7 : 4, - 'line-opacity': isActive ? 0.6 : 0, - }, - layout: { 'line-cap': 'round', 'line-join': 'round' } - }); - map.addLayer({ - id: 'route-line-' + i, - type: 'line', - source: 'route-' + i, - paint: { - 'line-color': color, - 'line-width': isActive ? 5 : 3, - 'line-opacity': isActive ? 0.95 : 0.5, - }, - layout: { 'line-cap': 'round', 'line-join': 'round' } - }); - - // Клик на линию маршрута - map.on('click', 'route-line-' + i, (e) => { - e.stopPropagation ? e.stopPropagation() : null; - selectRoute(i); - }); - map.on('mouseenter', 'route-line-' + i, () => { - map.getCanvas().style.cursor = 'pointer'; - highlightRoute(i); - }); - map.on('mouseleave', 'route-line-' + i, () => { - map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; - unhighlightRoute(i); - }); - }); - - activeRouteIdx = 0; - renderRouteCards(routeResults); - document.getElementById('route-status').textContent = `✅ ${routeResults.length} маршрут(ов)`; + + drawRouteResults(routeResults, 0); + + document.getElementById('route-status').textContent = `${routeResults.length} маршрут(ов)`; document.getElementById('route-actions').style.display = 'block'; - } catch(e) { document.getElementById('route-status').textContent = '❌ ' + e.message; + document.getElementById('route-cards').innerHTML = ''; } - - if (btn) btn.textContent = '🗺️ Построить маршрут'; } -// ─── Выбор и подсветка маршрутов ───────────────────────────────────────────── +function drawRouteResults(routes, activeIdx) { + const map = window._map; + activeRouteIdx = activeIdx; + routeResults = routes; + + // Clear old layers + for (let i = 0; i < 5; i++) { + try { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); } catch(e) {} + try { if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); } catch(e) {} + try { if (map.getSource('route-' + i)) map.removeSource('route-' + i); } catch(e) {} + } + + routes.forEach((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const isActive = i === activeIdx; + map.addSource('route-' + i, { + type: 'geojson', + data: { type: 'Feature', geometry: route.geometry, properties: {} } + }); + map.addLayer({ + id: 'route-line-' + i + '-outline', + type: 'line', source: 'route-' + i, + paint: { + 'line-color': '#ffffff', + 'line-width': isActive ? 7 : 4, + 'line-opacity': isActive ? 0.6 : 0, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + map.addLayer({ + id: 'route-line-' + i, + type: 'line', source: 'route-' + i, + paint: { + 'line-color': color, + 'line-width': isActive ? 5 : 3, + 'line-opacity': isActive ? 0.95 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + map.on('click', 'route-line-' + i, (e) => { + if (e.stopPropagation) e.stopPropagation(); + selectRoute(i); + }); + map.on('mouseenter', 'route-line-' + i, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'route-line-' + i, () => { + map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; + }); + }); + + renderRouteCards(routes); +} + function selectRoute(idx) { activeRouteIdx = idx; const map = window._map; routeResults.forEach((_, i) => { const isActive = i === idx; - if (map.getLayer('route-line-' + i)) { - map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); - map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5); - } - if (map.getLayer('route-line-' + i + '-outline')) { - map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4); - map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0); - } + try { + if (map.getLayer('route-line-' + i)) { + map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); + map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5); + } + if (map.getLayer('route-line-' + i + '-outline')) { + map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4); + map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0); + } + } catch(e) {} }); - // Обновляем CSS карточек document.querySelectorAll('.route-card').forEach((card, i) => { card.classList.toggle('active', i === idx); }); } -function highlightRoute(idx) { - const map = window._map; - if (map.getLayer('route-line-' + idx)) { - map.setPaintProperty('route-line-' + idx, 'line-width', 7); - map.setPaintProperty('route-line-' + idx, 'line-opacity', 1); - } -} - -function unhighlightRoute(idx) { - const isActive = idx === activeRouteIdx; - const map = window._map; - if (map.getLayer('route-line-' + idx)) { - map.setPaintProperty('route-line-' + idx, 'line-width', isActive ? 5 : 3); - map.setPaintProperty('route-line-' + idx, 'line-opacity', isActive ? 0.95 : 0.5); - } -} - -// ─── Карточки маршрутов ─────────────────────────────────────────────────────── +// ─── Карточки маршрутов ─────────────────────────────────────────── function renderRouteCards(routes) { const container = document.getElementById('route-cards'); container.innerHTML = routes.map((route, i) => { @@ -458,84 +624,34 @@ function renderRouteCards(routes) { const distKm = (route.distance_m / 1000).toFixed(1); const timeStr = formatDuration(route.duration_s); const isActive = i === activeRouteIdx; - - let barHtml = ''; - let summaryHtml = ''; - let detailsHtml = ''; - + + let statsHtml = ''; if (route.stats) { const s = route.stats; - barHtml = ` -
-
-
-
-
+ statsHtml = ` +
+
+
+
+
+ 🟡 ${s.dirt_total_pct || 0}% грунт + ${s.asphalt_pct ? `⬜ ${s.asphalt_pct}% асфальт` : ''}
`; - summaryHtml = `
${s.dirt_total_pct}% грунт · ${s.asphalt_pct}% асфальт
`; - detailsHtml = ` - - `; - } else { - detailsHtml = ` - - `; } - - return `
-
- - Вариант ${i + 1} - ${distKm} км - ${timeStr} + + return `
+
+ + Вариант ${i + 1} + ${distKm} км + ${timeStr}
- ${barHtml} - ${summaryHtml} - ${detailsHtml} + ${statsHtml}
`; }).join(''); } -function toggleRouteDetails(idx) { - const details = document.getElementById('route-details-' + idx); - const btn = details ? details.nextElementSibling : null; - if (!details) return; - const isOpen = details.style.display !== 'none'; - details.style.display = isOpen ? 'none' : 'block'; - if (btn) btn.textContent = isOpen ? 'Подробнее ▼' : 'Свернуть ▲'; -} - - -// ─── GPX экспорт ───────────────────────────────────────────────────────────── +// ─── GPX экспорт ─────────────────────────────────────────────────── function escapeXml(str) { return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } @@ -543,32 +659,26 @@ function escapeXml(str) { function downloadGPX() { const route = routeResults[activeRouteIdx]; if (!route) return; - const now = new Date(); const dateStr = now.toISOString().slice(0, 10); const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15); const filename = `enduro-${timeStr}.gpx`; - const distKm = (route.distance_m / 1000).toFixed(1); const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; - - // Waypoints: точки маршрута + const wpts = routeWaypoints.map((wp, i) => { const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`; return ` ${escapeXml(name)}`; }); - - // Добавить флажки из localStorage const markers = loadMarkers(); markers.forEach(m => { wpts.push(` ${escapeXml(m.name)}${escapeXml(m.icon)}`); }); - - // Трек + const trkpts = route.geometry.coordinates.map(([lon, lat]) => ` ` ).join('\n'); - + const gpx = ` @@ -584,7 +694,7 @@ ${trkpts} `; - + const blob = new Blob([gpx], { type: 'application/gpx+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -594,26 +704,17 @@ ${trkpts} URL.revokeObjectURL(url); } -// ─── Флажки / именованные метки ────────────────────────────────────────────── +// ─── Флажки / именованные метки ──────────────────────────────────── const MARKER_ICONS = ['🚩', '⛺', '🔧', '⛽', '💧', '📍']; const MARKERS_KEY = 'enduro_markers'; let markerMode = false; -let namedMarkerObjects = {}; // id -> MapLibre Marker +let namedMarkerObjects = {}; function loadMarkers() { - try { - return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]'); - } catch(e) { - return []; - } + try { return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]'); } catch(e) { return []; } } - function saveMarkers(markers) { - try { - localStorage.setItem(MARKERS_KEY, JSON.stringify(markers)); - } catch(e) { - console.warn('localStorage недоступен'); - } + try { localStorage.setItem(MARKERS_KEY, JSON.stringify(markers)); } catch(e) {} } function toggleMarkerMode() { @@ -632,64 +733,66 @@ function toggleMarkerMode() { function addMarker(lngLat) { const markers = loadMarkers(); - if (markers.length >= 50) { - alert('Достигнут лимит 50 меток'); - return; - } + if (markers.length >= 50) { alert('Достигнут лимит 50 меток'); return; } + + const grid = document.getElementById('marker-type-grid'); + // Show marker dialog + openMarkerDialog(lngLat); +} - // Простой диалог выбора иконки и имени - const iconChoice = promptIconChoice(); - if (iconChoice === null) return; // отмена +function openMarkerDialog(lngLat) { + const dialog = document.getElementById('marker-dialog'); + const grid = document.getElementById('marker-type-grid'); + grid.innerHTML = MARKER_ICONS.map((ic, i) => + `` + ).join(''); + dialog.classList.add('open'); +} - const rawName = prompt('Название метки (Enter = автоимя):'); - if (rawName === null) return; // отмена - const autoName = rawName.trim() || `Метка ${markers.length + 1}`; +function closeMarkerDialog() { + document.getElementById('marker-dialog').classList.remove('open'); +} - const marker = { - id: Date.now(), - name: autoName, - icon: iconChoice, - lat: lngLat.lat, - lon: lngLat.lng - }; +function selectMarkerType(idx, lat, lng) { + closeMarkerDialog(); + const markers = loadMarkers(); + const icon = MARKER_ICONS[idx] || MARKER_ICONS[0]; + const name = prompt('Название метки (Enter = автоимя):'); + if (name === null) return; + const autoName = name.trim() || `Метка ${markers.length + 1}`; + const marker = { id: Date.now(), name: autoName, icon, lat, lon: lng }; markers.push(marker); saveMarkers(markers); drawNamedMarker(marker); } -function promptIconChoice() { - const msg = 'Выберите иконку:\n' + MARKER_ICONS.map((ic, i) => `${i+1}. ${ic}`).join('\n') + '\n\nВведите номер (1-6) или Enter для 🚩:'; - const input = prompt(msg); - if (input === null) return null; - const idx = parseInt(input, 10) - 1; - if (idx >= 0 && idx < MARKER_ICONS.length) return MARKER_ICONS[idx]; - return MARKER_ICONS[0]; -} - function drawNamedMarker(markerData) { const map = window._map; + if (!map) return; const el = document.createElement('div'); - el.className = 'named-marker-el'; + el.className = 'named-marker-el marker-anim'; el.textContent = markerData.icon; el.title = markerData.name; - + const popup = new maplibregl.Popup({ offset: 25, closeButton: true }) .setHTML(`
- - - + + +
`); - + const mlMarker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) .setLngLat([markerData.lon, markerData.lat]) .setPopup(popup) .addTo(map); - - el.addEventListener('click', () => mlMarker.togglePopup()); + namedMarkerObjects[markerData.id] = mlMarker; } @@ -699,10 +802,7 @@ function renderMarkers() { } function removeMarker(id) { - if (namedMarkerObjects[id]) { - namedMarkerObjects[id].remove(); - delete namedMarkerObjects[id]; - } + if (namedMarkerObjects[id]) { namedMarkerObjects[id].remove(); delete namedMarkerObjects[id]; } const markers = loadMarkers().filter(m => m.id !== id); saveMarkers(markers); } @@ -711,19 +811,11 @@ function useMarkerAsA(id) { const markers = loadMarkers(); const m = markers.find(x => x.id === id); if (!m) return; - if (!routeMode) { - toggleRouteMode(); - } - if (routeWaypoints.length === 0) { - routeWaypoints.push({ lon: m.lon, lat: m.lat }); - } else { - routeWaypoints[0] = { lon: m.lon, lat: m.lat }; - } - rebuildWaypointMarkers(); - renderWaypointsList(); - updateRouteActionsVisibility(); + if (!routeMode) toggleRouteMode(); + if (routeWaypoints.length === 0) routeWaypoints.push({ lon: m.lon, lat: m.lat }); + else routeWaypoints[0] = { lon: m.lon, lat: m.lat }; + rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); - // Закрыть попап if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); } @@ -731,41 +823,16 @@ function useMarkerAsB(id) { const markers = loadMarkers(); const m = markers.find(x => x.id === id); if (!m) return; - if (!routeMode) { - toggleRouteMode(); - } - if (routeWaypoints.length === 0) { - routeWaypoints.push({ lon: m.lon, lat: m.lat }); - routeWaypoints.push({ lon: m.lon, lat: m.lat }); - } else if (routeWaypoints.length === 1) { - routeWaypoints.push({ lon: m.lon, lat: m.lat }); - } else { - routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat }; - } - rebuildWaypointMarkers(); - renderWaypointsList(); - updateRouteActionsVisibility(); + if (!routeMode) toggleRouteMode(); + if (routeWaypoints.length === 0) { routeWaypoints.push({ lon: m.lon, lat: m.lat }); routeWaypoints.push({ lon: m.lon, lat: m.lat }); } + else if (routeWaypoints.length === 1) routeWaypoints.push({ lon: m.lon, lat: m.lat }); + else routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat }; + rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); } -function clearAllMarkers() { - if (!confirm('Удалить все метки?')) return; - Object.values(namedMarkerObjects).forEach(m => m.remove()); - namedMarkerObjects = {}; - saveMarkers([]); -} - -function updateRouteActionsVisibility() { - document.getElementById('route-actions').style.display = - routeWaypoints.length >= 2 ? 'block' : 'none'; - document.getElementById('route-status').textContent = - routeWaypoints.length === 0 ? 'Кликни точку старта' : - routeWaypoints.length === 1 ? 'Кликни точку финиша' : ''; -} - - -// ─── Map init ───────────────────────────────────────────────────────────────── +// ─── Map init ────────────────────────────────────────────────────── async function initMap() { const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; const tileBase = window.location.origin + basePath; @@ -788,33 +855,28 @@ async function initMap() { map.addControl(new maplibregl.FullscreenControl(), 'top-left'); map.on('load', () => { - document.getElementById('loading').classList.remove('visible'); checkDataAvailability(); initRouteClicks(map); initRulerClicks(map); initSearch(); renderMarkers(); + // Apply theme on load + applyTheme(); + // Start auto-theme interval + themeAutoInterval = setInterval(() => { + if (themeMode === 'auto') applyAutoTheme(); + }, 60000); + }); + + map.on('style.load', () => { + onMapStyleLoad(); }); map.on('error', (e) => { console.error('Map error:', e.error?.message || e); - document.getElementById('loading').classList.remove('visible'); - }); - - setTimeout(() => { - document.getElementById('loading').classList.remove('visible'); - }, 15000); - - map.on('zoom', () => { - document.getElementById('zoom-val').textContent = map.getZoom().toFixed(1); - }); - - map.on('mousemove', (e) => { - const { lng, lat } = e.lngLat; - document.getElementById('coords-val').textContent = - `${lat.toFixed(4)}, ${lng.toFixed(4)}`; }); + // Popup for trail features const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, @@ -832,7 +894,7 @@ async function initMap() { 'natural=peak': '⛰ Вершина', 'natural=water': '💧 Вода', 'tourism=viewpoint': '👁 Смотровая', - 'historic=ruins': '🏙 Руины', + 'historic=ruins': '🏚 Руины', 'natural=cave_entrance': '🕳 Пещера', 'ford=yes': '🌊 Брод', }; @@ -841,7 +903,6 @@ async function initMap() { ['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => { map.on('click', layerId, (e) => { - // Не показываем попап трека если активен режим маршрута или линейки if (routeMode || rulerMode || markerMode) return; const props = e.features[0].properties; const html = ` @@ -889,7 +950,7 @@ async function initMap() { async function checkDataAvailability() { try { - const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const basePath = getBasePath(); const resp = await fetch(basePath + '/api/health'); const data = await resp.json(); if (!data.db_exists) { @@ -900,78 +961,58 @@ async function checkDataAvailability() { } } -// ─── Клики на карте (маршрут + метки) ──────────────────────────────────────── +// ─── Клики на карте ──────────────────────────────────────────────── function initRouteClicks(map) { map.on('click', (e) => { const { lng, lat } = e.lngLat; - // Режим Разведки - if (reconMode) { - doRecon(lng, lat); - return; - } - - // Режим Связки - if (linkMode) { - addLinkPoint(lng, lat); - return; - } - - // Режим Красивого маршрута + if (reconMode) { doRecon(lng, lat); return; } + if (linkMode) { addLinkPoint(lng, lat); return; } if (scenicMode) { scenicStart = { lon: lng, lat: lat }; document.getElementById('scenic-status').textContent = `📍 Старт: ${lat.toFixed(4)}, ${lng.toFixed(4)}`; if (scenicStartMarker) scenicStartMarker.remove(); const el = document.createElement('div'); - el.style.cssText = 'width:16px;height:16px;background:#ff6600;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; + el.className = 'marker-anim'; + el.style.cssText = 'width:16px;height:16px;background:var(--accent);border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; scenicStartMarker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map); + document.getElementById('btn-build-scenic').style.display = ''; return; } - - // Режим добавления метки if (markerMode) { addMarker(e.lngLat); toggleMarkerMode(); return; } - if (!routeMode) return; - // Режим добавления промежуточной точки if (addingWaypoint) { addingWaypoint = false; map.getCanvas().style.cursor = 'crosshair'; - // Вставляем перед последней точкой (B) if (routeWaypoints.length >= 2) { routeWaypoints.splice(routeWaypoints.length - 1, 0, { lon: lng, lat: lat }); } else { routeWaypoints.push({ lon: lng, lat: lat }); } - rebuildWaypointMarkers(); - renderWaypointsList(); - updateRouteActionsVisibility(); + rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); return; } - // Обычный режим: A → B if (routeWaypoints.length === 0) { routeWaypoints.push({ lon: lng, lat: lat }); - rebuildWaypointMarkers(); - renderWaypointsList(); - document.getElementById('route-status').textContent = 'Кликни точку финиша'; + rebuildWaypointMarkers(); renderWaypointsList(); + document.getElementById('route-status').textContent = 'Тапни точку финиша'; } else if (routeWaypoints.length === 1) { routeWaypoints.push({ lon: lng, lat: lat }); - rebuildWaypointMarkers(); - renderWaypointsList(); - updateRouteActionsVisibility(); + rebuildWaypointMarkers(); renderWaypointsList(); + document.getElementById('route-actions').style.display = 'block'; buildRoute(); } - // Если уже 2+ точек — клик ничего не делает (используй "+ Точка") }); } -// ─── Поиск (Nominatim) ──────────────────────────────────────────────────────── +// ─── Поиск (Nominatim) ───────────────────────────────────────────── let searchTimeout = null; function initSearch() { @@ -987,32 +1028,34 @@ function initSearch() { if (e.key === 'Escape') { results.style.display = 'none'; input.blur(); } }); document.addEventListener('click', (e) => { - if (!e.target.closest('#search-box')) results.style.display = 'none'; + if (!e.target.closest('#search-bar') && !e.target.closest('#search-results')) { + results.style.display = 'none'; + } }); } async function doSearch(query) { const results = document.getElementById('search-results'); - results.innerHTML = '
Поиск...
'; + results.innerHTML = '
Поиск...
'; results.style.display = 'block'; try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`; const resp = await fetch(url, { headers: { 'Accept-Language': 'ru' } }); const data = await resp.json(); if (!data.length) { - results.innerHTML = '
Ничего не найдено
'; + results.innerHTML = '
Ничего не найдено
'; return; } results.innerHTML = data.map((item) => { const name = item.display_name.split(',')[0]; const detail = item.display_name.split(',').slice(1, 3).join(',').trim(); return `
-
${name}
-
${detail}
+
${name}
+
${detail}
`; }).join(''); } catch(e) { - results.innerHTML = '
Ошибка поиска
'; + results.innerHTML = '
Ошибка поиска
'; } } @@ -1022,7 +1065,7 @@ function selectSearchResult(lat, lon, name) { document.getElementById('search-input').value = name; } -// ─── Линейка ────────────────────────────────────────────────────────────────── +// ─── Линейка ─────────────────────────────────────────────────────── let rulerMode = false; let rulerPoints = []; let rulerMarkers = []; @@ -1037,10 +1080,12 @@ function toggleRuler() { btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; clearRuler(); + document.getElementById('ruler-info').classList.add('visible'); } else { btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; clearRuler(); + document.getElementById('ruler-info').classList.remove('visible'); } } @@ -1050,8 +1095,8 @@ function clearRuler() { rulerMarkers.forEach(m => m.remove()); rulerMarkers = []; const map = window._map; - if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); - if (map.getSource('ruler')) map.removeSource('ruler'); + try { if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); } catch(e) {} + try { if (map.getSource('ruler')) map.removeSource('ruler'); } catch(e) {} } function haversineKm(a, b) { @@ -1074,49 +1119,28 @@ function updateRulerLine() { paint: { 'line-color': '#0088ff', 'line-width': 2, 'line-dasharray': [4, 2], 'line-opacity': 0.9 } }); } + // Update ruler info display + const dist = rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + document.getElementById('ruler-dist').textContent = dist; } function addRulerPoint(lngLat) { const map = window._map; const pt = [lngLat.lng, lngLat.lat]; - const idx = rulerPoints.length; rulerPoints.push(pt); - if (rulerPoints.length > 1) { rulerTotal += haversineKm(rulerPoints[rulerPoints.length - 2], pt); } - - const label = rulerPoints.length === 1 ? '0 м' : - rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; - const dot = document.createElement('div'); + dot.className = 'marker-anim'; dot.style.cssText = 'width:10px;height:10px;background:#0088ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3);'; const dotMarker = new maplibregl.Marker({ element: dot, anchor: 'center' }) .setLngLat([lngLat.lng, lngLat.lat]) .addTo(map); - - const labelEl = document.createElement('div'); - labelEl.style.cssText = 'display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.75);color:#fff;font-size:11px;font-weight:600;padding:2px 6px;border-radius:3px;white-space:nowrap;'; - labelEl.innerHTML = `${label}`; - const labelMarker = new maplibregl.Marker({ element: labelEl, anchor: 'center', offset: [0, -20] }) - .setLngLat([lngLat.lng, lngLat.lat]) - .addTo(map); - - rulerMarkers.push(dotMarker, labelMarker); + rulerMarkers.push(dotMarker); updateRulerLine(); } -function removeRulerPoint(idx) { - if (idx < 0 || idx >= rulerPoints.length) return; - rulerPoints.splice(idx, 1); - rulerMarkers.forEach(m => m.remove()); - rulerMarkers = []; - rulerTotal = 0; - const pts = [...rulerPoints]; - rulerPoints = []; - pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); -} - function initRulerClicks(map) { map.on('click', (e) => { if (!rulerMode) return; @@ -1129,7 +1153,7 @@ function initRulerClicks(map) { }); } -// ─── Фаза 4: Разведка ─────────────────────────────────────────────────────── +// ─── Фаза 4: Разведка ───────────────────────────────────────────── let reconMode = false; let reconCenter = null; let reconRadius = 20; @@ -1142,6 +1166,7 @@ function toggleReconMode() { reconMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; + openSheet('sheet-recon'); } else { btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; @@ -1163,11 +1188,7 @@ function makeCircleGeoJSON(lon, lat, radiusKm) { async function doRecon(lon, lat) { reconCenter = [lon, lat]; const map = window._map; - const panel = document.getElementById('recon-panel'); - panel.style.display = 'block'; - document.getElementById('recon-stats').innerHTML = 'Загружаю...'; - - // Draw circle + const circle = makeCircleGeoJSON(lon, lat, reconRadius); if (map.getSource('recon-circle')) { map.getSource('recon-circle').setData(circle); @@ -1183,8 +1204,10 @@ async function doRecon(lon, lat) { }); } - // API call - const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const basePath = getBasePath(); + const resultsDiv = document.getElementById('recon-results'); + resultsDiv.style.display = 'block'; + try { const resp = await fetch(`${basePath}/api/recon`, { method: 'POST', @@ -1194,24 +1217,34 @@ async function doRecon(lon, lat) { const data = await resp.json(); const t = data.trails || {}; const p = data.poi || {}; - document.getElementById('recon-stats').innerHTML = ` -
🛤 ${t.total_count || 0} грунтовок · ${t.total_km || 0} км
-
🟡 Lev1-2: ${t.lev12_count || 0} шт · ${t.lev12_km || 0} км
-
🔴 Lev3-5: ${t.lev345_count || 0} шт · ${t.lev345_km || 0} км
-
🔴 Тропы: ${t.path_count || 0} шт · ${t.path_km || 0} км
-
- 💧 Озёра: ${p['natural=water'] || 0} · 👁 Виды: ${p['tourism=viewpoint'] || 0}
- 🌊 Броды: ${p['ford=yes'] || 0} · 🏚 Руины: ${p['historic=ruins'] || 0} -
- `; + + document.getElementById('r-total-km').textContent = t.total_km || 0; + document.getElementById('r-lev12-km').textContent = t.lev12_km || 0; + document.getElementById('r-lev345-km').textContent = t.lev345_km || 0; + document.getElementById('r-path-km').textContent = t.path_km || 0; + + const poiList = document.getElementById('r-poi-list'); + const poiTypes = [ + { key: 'natural=water', icon: '💧', label: 'Озёра' }, + { key: 'tourism=viewpoint', icon: '👁', label: 'Смотровая' }, + { key: 'ford=yes', icon: '🌊', label: 'Броды' }, + { key: 'historic=ruins', icon: '🏚', label: 'Руины' }, + ]; + poiList.innerHTML = poiTypes.map(pt => + `
+ ${pt.icon} ${pt.label} + ${p[pt.key] || 0} +
` + ).join(''); + } catch(e) { - document.getElementById('recon-stats').innerHTML = 'Ошибка загрузки'; + document.getElementById('r-total-km').textContent = '—'; } } function setReconRadius(km) { reconRadius = km; - document.querySelectorAll('.recon-radius-btn').forEach(b => { + document.querySelectorAll('.seg-btn[data-km]').forEach(b => { b.classList.toggle('active', +b.dataset.km === km); }); if (reconCenter) doRecon(reconCenter[0], reconCenter[1]); @@ -1219,14 +1252,14 @@ function setReconRadius(km) { function clearRecon() { const map = window._map; - if (map.getLayer('recon-circle-fill')) map.removeLayer('recon-circle-fill'); - if (map.getLayer('recon-circle-stroke')) map.removeLayer('recon-circle-stroke'); - if (map.getSource('recon-circle')) map.removeSource('recon-circle'); + try { if (map.getLayer('recon-circle-fill')) map.removeLayer('recon-circle-fill'); } catch(e) {} + try { if (map.getLayer('recon-circle-stroke')) map.removeLayer('recon-circle-stroke'); } catch(e) {} + try { if (map.getSource('recon-circle')) map.removeSource('recon-circle'); } catch(e) {} closeSheet('sheet-recon'); reconCenter = null; } -// ─── Фаза 4: Связка ─────────────────────────────────────────────────────────── +// ─── Фаза 4: Связка ──────────────────────────────────────────────── let linkMode = false; let linkPoints = []; let linkMarkers = []; @@ -1240,8 +1273,11 @@ function toggleLinkMode() { btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; openSheet('sheet-link'); - document.getElementById('link-status').textContent = '1️⃣ Кликни конец первого трека'; + document.getElementById('link-status').textContent = '1️⃣ Тапни конец первого трека'; document.getElementById('link-cards').innerHTML = ''; + linkPoints = []; + linkMarkers.forEach(m => m.remove()); + linkMarkers = []; } else { btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; @@ -1254,14 +1290,20 @@ function addLinkPoint(lng, lat) { linkPoints.push({ lon: lng, lat: lat }); const idx = linkPoints.length; const el = document.createElement('div'); - el.style.cssText = 'width:16px;height:16px;background:#ff6600;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;'; + el.className = 'marker-anim'; + el.style.cssText = 'width:16px;height:16px;background:var(--accent);border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;'; el.textContent = idx; const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }).setLngLat([lng, lat]).addTo(map); linkMarkers.push(marker); if (idx === 1) { - document.getElementById('link-status').textContent = '2️⃣ Кликни начало второго трека'; + document.getElementById('link-pt-1').classList.remove('empty'); + document.getElementById('link-pt-1').querySelector('.link-pt-label').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + document.getElementById('link-status').textContent = '2️⃣ Тапни начало второго трека'; } else if (idx >= 2) { + document.getElementById('link-pt-2').classList.remove('empty'); + document.getElementById('link-pt-2').querySelector('.link-pt-label').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + showSkeleton('link-cards', 2); buildLinkRoute(); } } @@ -1269,7 +1311,7 @@ function addLinkPoint(lng, lat) { async function buildLinkRoute() { const map = window._map; document.getElementById('link-status').textContent = '⏳ Ищу связку...'; - const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const basePath = getBasePath(); try { const resp = await fetch(`${basePath}/api/route`, { method: 'POST', @@ -1299,8 +1341,8 @@ function renderLinkCards(routes) { const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; const sid = `link-src-${i}`; const lid = `link-line-${i}`; - if (map.getSource(sid)) map.removeSource(sid); - if (map.getLayer(lid)) map.removeLayer(lid); + try { if (map.getSource(sid)) map.removeSource(sid); } catch(e) {} + try { if (map.getLayer(lid)) map.removeLayer(lid); } catch(e) {} map.addSource(sid, { type: 'geojson', data: geojson }); map.addLayer({ id: lid, type: 'line', source: sid, @@ -1319,13 +1361,13 @@ function renderLinkCards(routes) { const card = document.createElement('div'); card.className = 'route-card' + (i === 0 ? ' active' : ''); card.innerHTML = ` -
- - Вариант ${i+1} - ${km} км - ${time} +
+ + Вариант ${i+1} + ${km} км + ${time}
-
${dirt}% грунт
+
${dirt}% грунт
`; card.onclick = () => selectLinkRoute(i); cardsEl.appendChild(card); @@ -1335,13 +1377,14 @@ function renderLinkCards(routes) { function selectLinkRoute(idx) { const map = window._map; document.querySelectorAll('#link-cards .route-card').forEach((c, i) => c.classList.toggle('active', i === idx)); - // Highlight selected route for (let i = 0; i < 3; i++) { const lid = `link-line-${i}`; - if (map.getLayer(lid)) { - map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); - map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); - } + try { + if (map.getLayer(lid)) { + map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); + map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); + } + } catch(e) {} } } @@ -1351,16 +1394,19 @@ function clearLink() { linkMarkers = []; const map = window._map; for (let i = 0; i < 5; i++) { - const lid = `link-line-${i}`; - if (map.getLayer(lid)) map.removeLayer(lid); - const sid = `link-src-${i}`; - if (map.getSource(sid)) map.removeSource(sid); + try { if (map.getLayer(`link-line-${i}`)) map.removeLayer(`link-line-${i}`); } catch(e) {} + try { if (map.getSource(`link-src-${i}`)) map.removeSource(`link-src-${i}`); } catch(e) {} } closeSheet('sheet-link'); document.getElementById('link-cards').innerHTML = ''; + // Reset link point UI + const pt1 = document.getElementById('link-pt-1'); + const pt2 = document.getElementById('link-pt-2'); + if (pt1) { pt1.classList.add('empty'); pt1.querySelector('.link-pt-label').textContent = 'Конец первого трека'; } + if (pt2) { pt2.classList.add('empty'); pt2.querySelector('.link-pt-label').textContent = 'Начало второго трека'; } } -// ─── Фаза 4: Красивый маршрут ──────────────────────────────────────────────── +// ─── Фаза 4: Красивый маршрут ────────────────────────────────────── let scenicMode = false; let scenicStart = null; let scenicStartMarker = null; @@ -1377,7 +1423,8 @@ function toggleScenicMode() { btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; openSheet('sheet-scenic'); - document.getElementById('scenic-status').textContent = 'Кликни точку старта на карте'; + document.getElementById('scenic-status').textContent = 'Тапни точку старта на карте'; + document.getElementById('btn-build-scenic').style.display = 'none'; } else { btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; @@ -1387,7 +1434,7 @@ function toggleScenicMode() { function setScenicKm(km) { scenicTargetKm = km; - document.querySelectorAll('.scenic-km-btn').forEach(b => { + document.querySelectorAll('#sheet-scenic .seg-btn[data-km]').forEach(b => { b.classList.toggle('active', +b.dataset.km === km); }); const inp = document.getElementById('scenic-custom-km'); @@ -1398,10 +1445,11 @@ async function buildScenicRoute() { if (!scenicStart) return; const map = window._map; document.getElementById('scenic-status').textContent = '⏳ Строю красивый маршрут...'; - document.getElementById('btn-build-scenic').textContent = '⏳ Строю...'; - document.getElementById('btn-build-scenic').disabled = true; + showSkeleton('scenic-cards', 2); + const btn = document.getElementById('btn-build-scenic'); + btn.disabled = true; - const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const basePath = getBasePath(); try { const resp = await fetch(`${basePath}/api/scenic`, { method: 'POST', @@ -1414,62 +1462,71 @@ async function buildScenicRoute() { } const data = await resp.json(); scenicRoutes = data.routes || []; - if (scenicRoutes.length === 0) throw new Error('Маршрут не найден'); - // Draw routes on map - const colors = ['#0066ff', '#00aa44', '#9933cc']; - scenicRoutes.forEach((r, i) => { - const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; - const sid = `scenic-src-${i}`; - const lid = `scenic-line-${i}`; - if (map.getLayer(lid)) map.removeLayer(lid); - if (map.getSource(sid)) map.removeSource(sid); - map.addSource(sid, { type: 'geojson', data: geojson }); - map.addLayer({ - id: lid, type: 'line', source: sid, - paint: { - 'line-color': colors[i % colors.length], - 'line-width': i === activeScenicIdx ? 5 : 3, - 'line-opacity': i === activeScenicIdx ? 0.9 : 0.5, - }, - layout: { 'line-cap': 'round', 'line-join': 'round' } - }); - }); - - // Show cards - const cardsEl = document.getElementById('scenic-cards'); - if (cardsEl) { - cardsEl.innerHTML = scenicRoutes.map((r, i) => { - const col = colors[i % colors.length]; - const km = (r.distance_m / 1000).toFixed(0); - const time = formatDuration(r.duration_s); - const dirt = r.stats?.dirt_total_pct || '?'; - const pois = (r.scenic_pois || []).map(p => { - const SCENIC_LABELS = {'natural=water':'💧 Озёро','tourism=viewpoint':'👁 Смотровая','historic=ruins':'🏚 Руины','natural=peak':'🔺 Вершина','natural=cave_entrance':'🕳 Пещера','ford=yes':'🌊 Брод'}; - const label = SCENIC_LABELS[p.type] || '📍 ' + p.type; - const name = p.name ? ` — ${p.name}` : ''; - return `
${label}${name}
`; - }).join(''); - return `
-
- - ${r.name || 'Вариант '+(i+1)} - ${km} км - ${time} -
-
${dirt}% грунт · score=${r.scenic_score||0}
- ${pois ? '
'+pois+'
' : ''} -
`; - }).join(''); - } + drawScenicRoutes(scenicRoutes, 0); document.getElementById('scenic-status').textContent = `✅ ${scenicRoutes.length} маршрут(ов)`; } catch(e) { document.getElementById('scenic-status').textContent = '❌ ' + e.message; + document.getElementById('scenic-cards').innerHTML = ''; + } + btn.disabled = false; +} + +function drawScenicRoutes(routes, activeIdx) { + const map = window._map; + scenicRoutes = routes; + activeScenicIdx = activeIdx; + + // Clear old + for (let i = 0; i < 10; i++) { + try { if (map.getLayer(`scenic-line-${i}`)) map.removeLayer(`scenic-line-${i}`); } catch(e) {} + try { if (map.getSource(`scenic-src-${i}`)) map.removeSource(`scenic-src-${i}`); } catch(e) {} + } + + const colors = ['#0066ff', '#00aa44', '#9933cc']; + routes.forEach((r, i) => { + const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; + const sid = `scenic-src-${i}`; + const lid = `scenic-line-${i}`; + map.addSource(sid, { type: 'geojson', data: geojson }); + map.addLayer({ + id: lid, type: 'line', source: sid, + paint: { + 'line-color': colors[i % colors.length], + 'line-width': i === activeIdx ? 5 : 3, + 'line-opacity': i === activeIdx ? 0.9 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + }); + + const cardsEl = document.getElementById('scenic-cards'); + if (cardsEl) { + cardsEl.innerHTML = routes.map((r, i) => { + const col = colors[i % colors.length]; + const km = (r.distance_m / 1000).toFixed(0); + const time = formatDuration(r.duration_s); + const dirt = r.stats?.dirt_total_pct || '?'; + const pois = (r.scenic_pois || []).map(p => { + const SCENIC_LABELS = {'natural=water':'💧 Озёро','tourism=viewpoint':'👁 Смотровая','historic=ruins':'🏚 Руины','natural=peak':'🔺 Вершина','natural=cave_entrance':'🕳 Пещера','ford=yes':'🌊 Брод'}; + const label = SCENIC_LABELS[p.type] || '📍 ' + p.type; + const name = p.name ? ` — ${p.name}` : ''; + return `
${label}${name}
`; + }).join(''); + return `
+
+ + ${r.name || 'Вариант '+(i+1)} + ${km} км + ${time} +
+
${dirt}% грунт · score=${r.scenic_score||0}
+ ${pois ? '
'+pois+'
' : ''} +
`; + }).join(''); } - document.getElementById('btn-build-scenic').textContent = '🎨 Построить маршрут'; - document.getElementById('btn-build-scenic').disabled = false; } function selectScenicRoute(idx) { @@ -1477,11 +1534,12 @@ function selectScenicRoute(idx) { const map = window._map; scenicRoutes.forEach((_, i) => { const lid = `scenic-line-${i}`; - if (map.getLayer(lid)) { - map.setLayoutProperty(lid, 'visibility', 'visible'); - map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); - map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); - } + try { + if (map.getLayer(lid)) { + map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); + map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); + } + } catch(e) {} }); document.querySelectorAll('#scenic-cards .route-card').forEach((c, i) => { c.classList.toggle('active', i === idx); @@ -1490,12 +1548,10 @@ function selectScenicRoute(idx) { function clearScenic() { const map = window._map; - scenicRoutes.forEach((_, i) => { - const lid = `scenic-line-${i}`; - const sid = `scenic-src-${i}`; - if (map.getLayer(lid)) map.removeLayer(lid); - if (map.getSource(sid)) map.removeSource(sid); - }); + for (let i = 0; i < 10; i++) { + try { if (map.getLayer(`scenic-line-${i}`)) map.removeLayer(`scenic-line-${i}`); } catch(e) {} + try { if (map.getSource(`scenic-src-${i}`)) map.removeSource(`scenic-src-${i}`); } catch(e) {} + } if (scenicStartMarker) { scenicStartMarker.remove(); scenicStartMarker = null; } scenicStart = null; scenicRoutes = []; @@ -1504,4 +1560,10 @@ function clearScenic() { if (cardsEl) cardsEl.innerHTML = ''; } -initMap(); +// ─── Init on page load ───────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + initMap(); + initSheetSwipe(); + // Apply saved theme immediately (before map loads) + applyTheme(); +}); diff --git a/tasks/enduro-trails/prototype/static/app.js.bak b/tasks/enduro-trails/prototype/static/app.js.bak new file mode 100644 index 0000000..fe9450a --- /dev/null +++ b/tasks/enduro-trails/prototype/static/app.js.bak @@ -0,0 +1,1507 @@ +// ─── Общее: деактивация всех режимов ──────────────────────────────────────────── + +function deactivateAllModes() { + if (routeMode) { routeMode = false; document.getElementById('tb-route').classList.remove('active'); closeSheet('sheet-route'); clearRoute(); } + if (rulerMode) toggleRuler(); + if (markerMode) toggleMarkerMode(); + if (typeof reconMode !== 'undefined' && reconMode) toggleReconMode(); + if (typeof linkMode !== 'undefined' && linkMode) toggleLinkMode(); + if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); + if (window._map) window._map.getCanvas().style.cursor = ''; +} + +// ─── Утилиты ────────────────────────────────────────────────────────────────── + +function formatDuration(seconds) { + const totalMin = Math.round(seconds / 60); + if (totalMin < 60) return totalMin + ' мин'; + const days = Math.floor(totalMin / 1440); + const hours = Math.floor((totalMin % 1440) / 60); + const mins = totalMin % 60; + if (days > 0) { + if (hours === 0 && mins === 0) return `${days} дн`; + if (mins === 0) return `${days} дн ${hours} ч`; + return `${days} дн ${hours} ч ${mins} мин`; + } + if (mins === 0) return `${hours} ч`; + return `${hours} ч ${mins} мин`; +} + +function formatDist(m) { + if (!m) return '—'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; +} + +// ─── Компас ─────────────────────────────────────────────────────────────────── +let compassLocked = false; + +function toggleCompass() { + const map = window._map; + if (!map) return; + const btn = document.getElementById('btn-compass'); + compassLocked = !compassLocked; + if (compassLocked) { + map.rotateTo(0, { duration: 300 }); + map.dragRotate.disable(); + map.touchZoomRotate.disableRotation(); + btn.textContent = '⬆️'; + btn.title = 'Север вверху (нажми для свободного вращения)'; + btn.classList.add('active'); + } else { + map.dragRotate.enable(); + map.touchZoomRotate.enableRotation(); + btn.textContent = '🧭'; + btn.title = 'Свободное вращение'; + btn.classList.remove('active'); + } +} + +// ─── Геолокация ─────────────────────────────────────────────────────────────── +let locationMarker = null; + +function locateMe() { + if (!navigator.geolocation) { + alert('Геолокация недоступна в этом браузере'); + return; + } + const btn = document.getElementById('btn-locate-x'); + btn.textContent = '⏳'; + navigator.geolocation.getCurrentPosition( + (pos) => { + const { longitude, latitude } = pos.coords; + const map = window._map; + btn.textContent = '🎯'; + map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 }); + if (locationMarker) { + locationMarker.setLngLat([longitude, latitude]); + } else { + const el = document.createElement('div'); + el.className = 'my-location-marker'; + el.innerHTML = '
'; + locationMarker = new maplibregl.Marker({ element: el, anchor: 'center' }) + .setLngLat([longitude, latitude]) + .addTo(map); + } + }, + (err) => { + btn.textContent = '🎯'; + alert('Не удалось определить местоположение: ' + err.message); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); +} + +// ─── Layer visibility state ─────────────────────────────────────────────────── +const layerState = { + tracks: true, + paths: true, + poi: true, + basemap: true, +}; + +const layerGroups = { + tracks: ['trails-track', 'trails-asphalt'], + paths: ['trails-path-bridleway'], + poi: ['poi-circles', 'poi-labels'], + basemap: ['osm-base'], +}; + +function toggleLayer(group) { + layerState[group] = !layerState[group]; + const btn = document.getElementById('btn-' + group); + btn.classList.toggle('active', layerState[group]); + const visibility = layerState[group] ? 'visible' : 'none'; + layerGroups[group].forEach(id => { + if (window._map && window._map.getLayer(id)) { + window._map.setLayoutProperty(id, 'visibility', visibility); + } + }); +} + + +// ─── Роутинг — состояние ────────────────────────────────────────────────────── +const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; + +let routeMode = false; +let routeWaypoints = []; // [{lon, lat}, ...] +let routeResults = []; // массив маршрутов из API +let activeRouteIdx = 0; +let waypointMarkers = []; // MapLibre маркеры точек +let addingWaypoint = false; // режим добавления промежуточной точки +let buildDebounceTimer = null; + +function getBasePath() { + return window.location.pathname.replace(/\/[^/]*$/, '') || ''; +} + +// ─── Режим маршрута ─────────────────────────────────────────────────────────── +function toggleRouteMode() { + routeMode = !routeMode; + const btn = document.getElementById('tb-route'); + const panel = document.getElementById('route-panel'); + if (routeMode) { + deactivateAllModes(); + routeMode = true; + btn.classList.add('active'); + panel.style.display = 'block'; + clearRoute(); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + panel.style.display = 'none'; + clearRoute(); + window._map.getCanvas().style.cursor = ''; + } +} + +function clearRoute() { + // Убираем маркеры точек + waypointMarkers.forEach(m => m.remove()); + waypointMarkers = []; + routeWaypoints = []; + routeResults = []; + activeRouteIdx = 0; + addingWaypoint = false; + + // Убираем слои маршрутов + const map = window._map; + if (map) { + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + } + if (map.getSource('route')) map.removeSource('route'); + for (let i = 0; i < 5; i++) { + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + } + + document.getElementById('route-status').textContent = 'Кликни точку старта'; + document.getElementById('route-actions').style.display = 'none'; + document.getElementById('route-cards').innerHTML = ''; + document.getElementById('waypoints-list').innerHTML = ''; + document.getElementById('btn-add-waypoint').style.display = ''; + + if (routeMode && map) map.getCanvas().style.cursor = 'crosshair'; +} + +// ─── Добавление промежуточной точки ────────────────────────────────────────── +function startAddWaypoint() { + if (routeWaypoints.length >= 10) return; + addingWaypoint = true; + window._map.getCanvas().style.cursor = 'crosshair'; + document.getElementById('route-status').textContent = 'Кликни на карте для добавления точки'; +} + +// ─── Маркеры точек ──────────────────────────────────────────────────────────── +function createWaypointMarkerEl(index, total) { + const el = document.createElement('div'); + el.className = 'route-waypoint-marker'; + let bg, text, color = '#fff'; + if (index === 0) { + bg = '#00aa44'; text = 'A'; + el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + } else if (index === total - 1) { + bg = '#cc0000'; text = 'B'; + el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + } else { + text = String(index); + el.style.cssText = `width:18px;height:18px;background:#fff;border:2px solid #0066ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#0066ff;box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + } + el.textContent = text; + return el; +} + +function rebuildWaypointMarkers() { + waypointMarkers.forEach(m => m.remove()); + waypointMarkers = []; + const map = window._map; + routeWaypoints.forEach((wp, i) => { + const el = createWaypointMarkerEl(i, routeWaypoints.length); + const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }) + .setLngLat([wp.lon, wp.lat]) + .addTo(map); + + // Захватываем индекс в замыкании + (function(idx) { + marker.on('dragend', () => { + const lngLat = marker.getLngLat(); + routeWaypoints[idx] = { lon: lngLat.lng, lat: lngLat.lat }; + renderWaypointsList(); + debounceBuildRoute(); + }); + })(i); + + waypointMarkers.push(marker); + }); +} + +function renderWaypointsList() { + const list = document.getElementById('waypoints-list'); + if (!routeWaypoints.length) { list.innerHTML = ''; return; } + + list.innerHTML = routeWaypoints.map((wp, i) => { + let labelClass, labelText; + if (i === 0) { labelClass = 'start'; labelText = 'A'; } + else if (i === routeWaypoints.length - 1) { labelClass = 'end'; labelText = 'B'; } + else { labelClass = 'mid'; labelText = String(i); } + + return `
+ ${labelText} + ${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)} + +
`; + }).join(''); + + // Показываем/скрываем кнопку добавления + document.getElementById('btn-add-waypoint').style.display = + routeWaypoints.length >= 10 ? 'none' : ''; +} + +// ─── Drag-and-drop порядка точек ────────────────────────────────────────────── +let dragWpIdx = null; + +function onWpDragStart(e, idx) { + dragWpIdx = idx; + e.dataTransfer.effectAllowed = 'move'; +} + +function onWpDragOver(e, idx) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + document.querySelectorAll('.waypoint-row').forEach((r, i) => { + r.classList.toggle('drag-over', i === idx); + }); +} + +function onWpDragLeave(e) { + e.currentTarget.classList.remove('drag-over'); +} + +function onWpDrop(e, idx) { + e.preventDefault(); + document.querySelectorAll('.waypoint-row').forEach(r => r.classList.remove('drag-over')); + if (dragWpIdx === null || dragWpIdx === idx) return; + const moved = routeWaypoints.splice(dragWpIdx, 1)[0]; + routeWaypoints.splice(idx, 0, moved); + dragWpIdx = null; + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); +} + +function removeWaypoint(idx) { + routeWaypoints.splice(idx, 1); + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) { + debounceBuildRoute(); + } else { + // Убираем маршруты с карты + const map = window._map; + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + routeResults = []; + document.getElementById('route-cards').innerHTML = ''; + document.getElementById('route-actions').style.display = + routeWaypoints.length >= 2 ? 'block' : 'none'; + document.getElementById('route-status').textContent = + routeWaypoints.length === 0 ? 'Кликни точку старта' : + routeWaypoints.length === 1 ? 'Кликни точку финиша' : ''; + } +} + + +// ─── Построение маршрута ────────────────────────────────────────────────────── +function debounceBuildRoute() { + clearTimeout(buildDebounceTimer); + buildDebounceTimer = setTimeout(buildRoute, 300); +} + +async function buildRoute() { + if (routeWaypoints.length < 2) return; + const map = window._map; + const basePath = getBasePath(); + + document.getElementById('route-status').textContent = '⏳ Строю маршрут...'; + const btn = document.getElementById('btn-build-route'); + if (btn) btn.textContent = '⏳ Строю...'; + + try { + const resp = await fetch(basePath + '/api/route', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ waypoints: routeWaypoints, alternatives: 5 }), + }); + if (!resp.ok) throw new Error('Маршрут не найден'); + const data = await resp.json(); + routeResults = data.routes || []; + if (!routeResults.length) throw new Error('Маршрут не найден'); + + // Убираем старые слои + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + + // Рисуем все маршруты + routeResults.forEach((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const isActive = i === activeRouteIdx; + map.addSource('route-' + i, { + type: 'geojson', + data: { type: 'Feature', geometry: route.geometry, properties: {} } + }); + // Обводка (белая) для активного + map.addLayer({ + id: 'route-line-' + i + '-outline', + type: 'line', + source: 'route-' + i, + paint: { + 'line-color': '#ffffff', + 'line-width': isActive ? 7 : 4, + 'line-opacity': isActive ? 0.6 : 0, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + map.addLayer({ + id: 'route-line-' + i, + type: 'line', + source: 'route-' + i, + paint: { + 'line-color': color, + 'line-width': isActive ? 5 : 3, + 'line-opacity': isActive ? 0.95 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + // Клик на линию маршрута + map.on('click', 'route-line-' + i, (e) => { + e.stopPropagation ? e.stopPropagation() : null; + selectRoute(i); + }); + map.on('mouseenter', 'route-line-' + i, () => { + map.getCanvas().style.cursor = 'pointer'; + highlightRoute(i); + }); + map.on('mouseleave', 'route-line-' + i, () => { + map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; + unhighlightRoute(i); + }); + }); + + activeRouteIdx = 0; + renderRouteCards(routeResults); + document.getElementById('route-status').textContent = `✅ ${routeResults.length} маршрут(ов)`; + document.getElementById('route-actions').style.display = 'block'; + + } catch(e) { + document.getElementById('route-status').textContent = '❌ ' + e.message; + } + + if (btn) btn.textContent = '🗺️ Построить маршрут'; +} + +// ─── Выбор и подсветка маршрутов ───────────────────────────────────────────── +function selectRoute(idx) { + activeRouteIdx = idx; + const map = window._map; + routeResults.forEach((_, i) => { + const isActive = i === idx; + if (map.getLayer('route-line-' + i)) { + map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); + map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5); + } + if (map.getLayer('route-line-' + i + '-outline')) { + map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4); + map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0); + } + }); + // Обновляем CSS карточек + document.querySelectorAll('.route-card').forEach((card, i) => { + card.classList.toggle('active', i === idx); + }); +} + +function highlightRoute(idx) { + const map = window._map; + if (map.getLayer('route-line-' + idx)) { + map.setPaintProperty('route-line-' + idx, 'line-width', 7); + map.setPaintProperty('route-line-' + idx, 'line-opacity', 1); + } +} + +function unhighlightRoute(idx) { + const isActive = idx === activeRouteIdx; + const map = window._map; + if (map.getLayer('route-line-' + idx)) { + map.setPaintProperty('route-line-' + idx, 'line-width', isActive ? 5 : 3); + map.setPaintProperty('route-line-' + idx, 'line-opacity', isActive ? 0.95 : 0.5); + } +} + +// ─── Карточки маршрутов ─────────────────────────────────────────────────────── +function renderRouteCards(routes) { + const container = document.getElementById('route-cards'); + container.innerHTML = routes.map((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const distKm = (route.distance_m / 1000).toFixed(1); + const timeStr = formatDuration(route.duration_s); + const isActive = i === activeRouteIdx; + + let barHtml = ''; + let summaryHtml = ''; + let detailsHtml = ''; + + if (route.stats) { + const s = route.stats; + barHtml = ` +
+
+
+
+
+
`; + summaryHtml = `
${s.dirt_total_pct}% грунт · ${s.asphalt_pct}% асфальт
`; + detailsHtml = ` + + `; + } else { + detailsHtml = ` + + `; + } + + return `
+
+ + Вариант ${i + 1} + ${distKm} км + ${timeStr} +
+ ${barHtml} + ${summaryHtml} + ${detailsHtml} +
`; + }).join(''); +} + +function toggleRouteDetails(idx) { + const details = document.getElementById('route-details-' + idx); + const btn = details ? details.nextElementSibling : null; + if (!details) return; + const isOpen = details.style.display !== 'none'; + details.style.display = isOpen ? 'none' : 'block'; + if (btn) btn.textContent = isOpen ? 'Подробнее ▼' : 'Свернуть ▲'; +} + + +// ─── GPX экспорт ───────────────────────────────────────────────────────────── +function escapeXml(str) { + return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function downloadGPX() { + const route = routeResults[activeRouteIdx]; + if (!route) return; + + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10); + const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15); + const filename = `enduro-${timeStr}.gpx`; + + const distKm = (route.distance_m / 1000).toFixed(1); + const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; + + // Waypoints: точки маршрута + const wpts = routeWaypoints.map((wp, i) => { + const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`; + return ` ${escapeXml(name)}`; + }); + + // Добавить флажки из localStorage + const markers = loadMarkers(); + markers.forEach(m => { + wpts.push(` ${escapeXml(m.name)}${escapeXml(m.icon)}`); + }); + + // Трек + const trkpts = route.geometry.coordinates.map(([lon, lat]) => + ` ` + ).join('\n'); + + const gpx = ` + + + Enduro route ${dateStr} + ${distKm} км · ${dirtPct}% грунт + + +${wpts.join('\n')} + + Enduro route ${dateStr} + +${trkpts} + + +`; + + const blob = new Blob([gpx], { type: 'application/gpx+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +// ─── Флажки / именованные метки ────────────────────────────────────────────── +const MARKER_ICONS = ['🚩', '⛺', '🔧', '⛽', '💧', '📍']; +const MARKERS_KEY = 'enduro_markers'; +let markerMode = false; +let namedMarkerObjects = {}; // id -> MapLibre Marker + +function loadMarkers() { + try { + return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]'); + } catch(e) { + return []; + } +} + +function saveMarkers(markers) { + try { + localStorage.setItem(MARKERS_KEY, JSON.stringify(markers)); + } catch(e) { + console.warn('localStorage недоступен'); + } +} + +function toggleMarkerMode() { + markerMode = !markerMode; + const btn = document.getElementById('tb-marker'); + if (markerMode) { + deactivateAllModes(); + markerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + } +} + +function addMarker(lngLat) { + const markers = loadMarkers(); + if (markers.length >= 50) { + alert('Достигнут лимит 50 меток'); + return; + } + + // Простой диалог выбора иконки и имени + const iconChoice = promptIconChoice(); + if (iconChoice === null) return; // отмена + + const rawName = prompt('Название метки (Enter = автоимя):'); + if (rawName === null) return; // отмена + const autoName = rawName.trim() || `Метка ${markers.length + 1}`; + + const marker = { + id: Date.now(), + name: autoName, + icon: iconChoice, + lat: lngLat.lat, + lon: lngLat.lng + }; + markers.push(marker); + saveMarkers(markers); + drawNamedMarker(marker); +} + +function promptIconChoice() { + const msg = 'Выберите иконку:\n' + MARKER_ICONS.map((ic, i) => `${i+1}. ${ic}`).join('\n') + '\n\nВведите номер (1-6) или Enter для 🚩:'; + const input = prompt(msg); + if (input === null) return null; + const idx = parseInt(input, 10) - 1; + if (idx >= 0 && idx < MARKER_ICONS.length) return MARKER_ICONS[idx]; + return MARKER_ICONS[0]; +} + +function drawNamedMarker(markerData) { + const map = window._map; + const el = document.createElement('div'); + el.className = 'named-marker-el'; + el.textContent = markerData.icon; + el.title = markerData.name; + + const popup = new maplibregl.Popup({ offset: 25, closeButton: true }) + .setHTML(` + + +
+ + + +
+ `); + + const mlMarker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([markerData.lon, markerData.lat]) + .setPopup(popup) + .addTo(map); + + el.addEventListener('click', () => mlMarker.togglePopup()); + namedMarkerObjects[markerData.id] = mlMarker; +} + +function renderMarkers() { + const markers = loadMarkers(); + markers.forEach(m => drawNamedMarker(m)); +} + +function removeMarker(id) { + if (namedMarkerObjects[id]) { + namedMarkerObjects[id].remove(); + delete namedMarkerObjects[id]; + } + const markers = loadMarkers().filter(m => m.id !== id); + saveMarkers(markers); +} + +function useMarkerAsA(id) { + const markers = loadMarkers(); + const m = markers.find(x => x.id === id); + if (!m) return; + if (!routeMode) { + toggleRouteMode(); + } + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + } else { + routeWaypoints[0] = { lon: m.lon, lat: m.lat }; + } + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + // Закрыть попап + if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); +} + +function useMarkerAsB(id) { + const markers = loadMarkers(); + const m = markers.find(x => x.id === id); + if (!m) return; + if (!routeMode) { + toggleRouteMode(); + } + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + } else if (routeWaypoints.length === 1) { + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + } else { + routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat }; + } + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); +} + +function clearAllMarkers() { + if (!confirm('Удалить все метки?')) return; + Object.values(namedMarkerObjects).forEach(m => m.remove()); + namedMarkerObjects = {}; + saveMarkers([]); +} + +function updateRouteActionsVisibility() { + document.getElementById('route-actions').style.display = + routeWaypoints.length >= 2 ? 'block' : 'none'; + document.getElementById('route-status').textContent = + routeWaypoints.length === 0 ? 'Кликни точку старта' : + routeWaypoints.length === 1 ? 'Кликни точку финиша' : ''; +} + + +// ─── Map init ───────────────────────────────────────────────────────────────── +async function initMap() { + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const tileBase = window.location.origin + basePath; + const style = await fetch(basePath + '/style.json').then(r => r.json()); + style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`]; + + const map = new maplibregl.Map({ + container: 'map', + style: style, + center: [40.5, 55.5], + zoom: 7, + minZoom: 4, + maxZoom: 18, + hash: true, + }); + window._map = map; + + map.addControl(new maplibregl.NavigationControl(), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right'); + map.addControl(new maplibregl.FullscreenControl(), 'top-left'); + + map.on('load', () => { + document.getElementById('loading').classList.remove('visible'); + checkDataAvailability(); + initRouteClicks(map); + initRulerClicks(map); + initSearch(); + renderMarkers(); + }); + + map.on('error', (e) => { + console.error('Map error:', e.error?.message || e); + document.getElementById('loading').classList.remove('visible'); + }); + + setTimeout(() => { + document.getElementById('loading').classList.remove('visible'); + }, 15000); + + map.on('zoom', () => { + document.getElementById('zoom-val').textContent = map.getZoom().toFixed(1); + }); + + map.on('mousemove', (e) => { + const { lng, lat } = e.lngLat; + document.getElementById('coords-val').textContent = + `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + }); + + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: false, + maxWidth: '300px', + }); + + function formatLength(m) { + if (!m) return '—'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; + } + + function poiTypeLabel(t) { + const labels = { + 'natural=peak': '⛰ Вершина', + 'natural=water': '💧 Вода', + 'tourism=viewpoint': '👁 Смотровая', + 'historic=ruins': '🏙 Руины', + 'natural=cave_entrance': '🕳 Пещера', + 'ford=yes': '🌊 Брод', + }; + return labels[t] || t; + } + + ['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => { + map.on('click', layerId, (e) => { + // Не показываем попап трека если активен режим маршрута или линейки + if (routeMode || rulerMode || markerMode) return; + const props = e.features[0].properties; + const html = ` + + + + + + ${props.mtb_scale ? `` : ''} + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', layerId, () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', layerId, () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; + }); + }); + + map.on('click', 'poi-circles', (e) => { + if (routeMode || rulerMode || markerMode) return; + const props = e.features[0].properties; + const html = ` + + + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', 'poi-circles', () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'poi-circles', () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; + }); + + map.on('click', (e) => { + if (routeMode || rulerMode || markerMode) return; + const features = map.queryRenderedFeatures(e.point, { + layers: ['trails-track', 'trails-path-bridleway', 'trails-asphalt', 'poi-circles'], + }); + if (!features.length) popup.remove(); + }); +} + +async function checkDataAvailability() { + try { + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const resp = await fetch(basePath + '/api/health'); + const data = await resp.json(); + if (!data.db_exists) { + document.getElementById('no-data-warning').classList.add('visible'); + } + } catch (e) { + console.warn('Health check failed:', e); + } +} + +// ─── Клики на карте (маршрут + метки) ──────────────────────────────────────── +function initRouteClicks(map) { + map.on('click', (e) => { + const { lng, lat } = e.lngLat; + + // Режим Разведки + if (reconMode) { + doRecon(lng, lat); + return; + } + + // Режим Связки + if (linkMode) { + addLinkPoint(lng, lat); + return; + } + + // Режим Красивого маршрута + if (scenicMode) { + scenicStart = { lon: lng, lat: lat }; + document.getElementById('scenic-status').textContent = `📍 Старт: ${lat.toFixed(4)}, ${lng.toFixed(4)}`; + if (scenicStartMarker) scenicStartMarker.remove(); + const el = document.createElement('div'); + el.style.cssText = 'width:16px;height:16px;background:#ff6600;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; + scenicStartMarker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map); + return; + } + + // Режим добавления метки + if (markerMode) { + addMarker(e.lngLat); + toggleMarkerMode(); + return; + } + + if (!routeMode) return; + + // Режим добавления промежуточной точки + if (addingWaypoint) { + addingWaypoint = false; + map.getCanvas().style.cursor = 'crosshair'; + // Вставляем перед последней точкой (B) + if (routeWaypoints.length >= 2) { + routeWaypoints.splice(routeWaypoints.length - 1, 0, { lon: lng, lat: lat }); + } else { + routeWaypoints.push({ lon: lng, lat: lat }); + } + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + return; + } + + // Обычный режим: A → B + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); + renderWaypointsList(); + document.getElementById('route-status').textContent = 'Кликни точку финиша'; + } else if (routeWaypoints.length === 1) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + buildRoute(); + } + // Если уже 2+ точек — клик ничего не делает (используй "+ Точка") + }); +} + +// ─── Поиск (Nominatim) ──────────────────────────────────────────────────────── +let searchTimeout = null; + +function initSearch() { + const input = document.getElementById('search-input'); + const results = document.getElementById('search-results'); + input.addEventListener('input', () => { + clearTimeout(searchTimeout); + const q = input.value.trim(); + if (q.length < 2) { results.style.display = 'none'; return; } + searchTimeout = setTimeout(() => doSearch(q), 400); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { results.style.display = 'none'; input.blur(); } + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('#search-box')) results.style.display = 'none'; + }); +} + +async function doSearch(query) { + const results = document.getElementById('search-results'); + results.innerHTML = '
Поиск...
'; + results.style.display = 'block'; + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`; + const resp = await fetch(url, { headers: { 'Accept-Language': 'ru' } }); + const data = await resp.json(); + if (!data.length) { + results.innerHTML = '
Ничего не найдено
'; + return; + } + results.innerHTML = data.map((item) => { + const name = item.display_name.split(',')[0]; + const detail = item.display_name.split(',').slice(1, 3).join(',').trim(); + return `
+
${name}
+
${detail}
+
`; + }).join(''); + } catch(e) { + results.innerHTML = '
Ошибка поиска
'; + } +} + +function selectSearchResult(lat, lon, name) { + window._map.flyTo({ center: [lon, lat], zoom: 13, duration: 800 }); + document.getElementById('search-results').style.display = 'none'; + document.getElementById('search-input').value = name; +} + +// ─── Линейка ────────────────────────────────────────────────────────────────── +let rulerMode = false; +let rulerPoints = []; +let rulerMarkers = []; +let rulerTotal = 0; + +function toggleRuler() { + rulerMode = !rulerMode; + const btn = document.getElementById('tb-ruler'); + if (rulerMode) { + deactivateAllModes(); + rulerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + clearRuler(); + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + clearRuler(); + } +} + +function clearRuler() { + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); + if (map.getSource('ruler')) map.removeSource('ruler'); +} + +function haversineKm(a, b) { + const R = 6371; + const dLat = (b[1] - a[1]) * Math.PI / 180; + const dLon = (b[0] - a[0]) * Math.PI / 180; + const s = Math.sin(dLat/2)**2 + Math.cos(a[1]*Math.PI/180) * Math.cos(b[1]*Math.PI/180) * Math.sin(dLon/2)**2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); +} + +function updateRulerLine() { + const map = window._map; + const geojson = { type: 'Feature', geometry: { type: 'LineString', coordinates: rulerPoints } }; + if (map.getSource('ruler')) { + map.getSource('ruler').setData(geojson); + } else { + map.addSource('ruler', { type: 'geojson', data: geojson }); + map.addLayer({ + id: 'ruler-line', type: 'line', source: 'ruler', + paint: { 'line-color': '#0088ff', 'line-width': 2, 'line-dasharray': [4, 2], 'line-opacity': 0.9 } + }); + } +} + +function addRulerPoint(lngLat) { + const map = window._map; + const pt = [lngLat.lng, lngLat.lat]; + const idx = rulerPoints.length; + rulerPoints.push(pt); + + if (rulerPoints.length > 1) { + rulerTotal += haversineKm(rulerPoints[rulerPoints.length - 2], pt); + } + + const label = rulerPoints.length === 1 ? '0 м' : + rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + + const dot = document.createElement('div'); + dot.style.cssText = 'width:10px;height:10px;background:#0088ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3);'; + const dotMarker = new maplibregl.Marker({ element: dot, anchor: 'center' }) + .setLngLat([lngLat.lng, lngLat.lat]) + .addTo(map); + + const labelEl = document.createElement('div'); + labelEl.style.cssText = 'display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.75);color:#fff;font-size:11px;font-weight:600;padding:2px 6px;border-radius:3px;white-space:nowrap;'; + labelEl.innerHTML = `${label}`; + const labelMarker = new maplibregl.Marker({ element: labelEl, anchor: 'center', offset: [0, -20] }) + .setLngLat([lngLat.lng, lngLat.lat]) + .addTo(map); + + rulerMarkers.push(dotMarker, labelMarker); + updateRulerLine(); +} + +function removeRulerPoint(idx) { + if (idx < 0 || idx >= rulerPoints.length) return; + rulerPoints.splice(idx, 1); + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + rulerTotal = 0; + const pts = [...rulerPoints]; + rulerPoints = []; + pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); +} + +function initRulerClicks(map) { + map.on('click', (e) => { + if (!rulerMode) return; + addRulerPoint(e.lngLat); + }); + map.on('dblclick', (e) => { + if (!rulerMode) return; + e.preventDefault(); + toggleRuler(); + }); +} + +// ─── Фаза 4: Разведка ─────────────────────────────────────────────────────── +let reconMode = false; +let reconCenter = null; +let reconRadius = 20; + +function toggleReconMode() { + reconMode = !reconMode; + const btn = document.getElementById('tb-recon'); + if (reconMode) { + deactivateAllModes(); + reconMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + clearRecon(); + } +} + +function makeCircleGeoJSON(lon, lat, radiusKm) { + const coords = []; + for (let i = 0; i <= 64; i++) { + const a = (2 * Math.PI * i) / 64; + const dlat = (radiusKm / 111) * Math.cos(a); + const dlon = (radiusKm / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(a); + coords.push([lon + dlon, lat + dlat]); + } + return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: {} }; +} + +async function doRecon(lon, lat) { + reconCenter = [lon, lat]; + const map = window._map; + const panel = document.getElementById('recon-panel'); + panel.style.display = 'block'; + document.getElementById('recon-stats').innerHTML = 'Загружаю...'; + + // Draw circle + const circle = makeCircleGeoJSON(lon, lat, reconRadius); + if (map.getSource('recon-circle')) { + map.getSource('recon-circle').setData(circle); + } else { + map.addSource('recon-circle', { type: 'geojson', data: circle }); + map.addLayer({ + id: 'recon-circle-fill', type: 'fill', source: 'recon-circle', + paint: { 'fill-color': '#ff6600', 'fill-opacity': 0.08 } + }); + map.addLayer({ + id: 'recon-circle-stroke', type: 'line', source: 'recon-circle', + paint: { 'line-color': '#ff6600', 'line-width': 2, 'line-opacity': 0.5 } + }); + } + + // API call + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + try { + const resp = await fetch(`${basePath}/api/recon`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lon, lat, radius_km: reconRadius }) + }); + const data = await resp.json(); + const t = data.trails || {}; + const p = data.poi || {}; + document.getElementById('recon-stats').innerHTML = ` +
🛤 ${t.total_count || 0} грунтовок · ${t.total_km || 0} км
+
🟡 Lev1-2: ${t.lev12_count || 0} шт · ${t.lev12_km || 0} км
+
🔴 Lev3-5: ${t.lev345_count || 0} шт · ${t.lev345_km || 0} км
+
🔴 Тропы: ${t.path_count || 0} шт · ${t.path_km || 0} км
+
+ 💧 Озёра: ${p['natural=water'] || 0} · 👁 Виды: ${p['tourism=viewpoint'] || 0}
+ 🌊 Броды: ${p['ford=yes'] || 0} · 🏚 Руины: ${p['historic=ruins'] || 0} +
+ `; + } catch(e) { + document.getElementById('recon-stats').innerHTML = 'Ошибка загрузки'; + } +} + +function setReconRadius(km) { + reconRadius = km; + document.querySelectorAll('.recon-radius-btn').forEach(b => { + b.classList.toggle('active', +b.dataset.km === km); + }); + if (reconCenter) doRecon(reconCenter[0], reconCenter[1]); +} + +function clearRecon() { + const map = window._map; + if (map.getLayer('recon-circle-fill')) map.removeLayer('recon-circle-fill'); + if (map.getLayer('recon-circle-stroke')) map.removeLayer('recon-circle-stroke'); + if (map.getSource('recon-circle')) map.removeSource('recon-circle'); + closeSheet('sheet-recon'); + reconCenter = null; +} + +// ─── Фаза 4: Связка ─────────────────────────────────────────────────────────── +let linkMode = false; +let linkPoints = []; +let linkMarkers = []; + +function toggleLinkMode() { + linkMode = !linkMode; + const btn = document.getElementById('tb-link'); + if (linkMode) { + deactivateAllModes(); + linkMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + openSheet('sheet-link'); + document.getElementById('link-status').textContent = '1️⃣ Кликни конец первого трека'; + document.getElementById('link-cards').innerHTML = ''; + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + clearLink(); + } +} + +function addLinkPoint(lng, lat) { + const map = window._map; + linkPoints.push({ lon: lng, lat: lat }); + const idx = linkPoints.length; + const el = document.createElement('div'); + el.style.cssText = 'width:16px;height:16px;background:#ff6600;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;'; + el.textContent = idx; + const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }).setLngLat([lng, lat]).addTo(map); + linkMarkers.push(marker); + + if (idx === 1) { + document.getElementById('link-status').textContent = '2️⃣ Кликни начало второго трека'; + } else if (idx >= 2) { + buildLinkRoute(); + } +} + +async function buildLinkRoute() { + const map = window._map; + document.getElementById('link-status').textContent = '⏳ Ищу связку...'; + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + try { + const resp = await fetch(`${basePath}/api/route`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ waypoints: linkPoints, alternatives: 3 }) + }); + if (!resp.ok) throw new Error('Не найдена'); + const data = await resp.json(); + if (data.routes && data.routes.length > 0) { + renderLinkCards(data.routes); + document.getElementById('link-status').textContent = '✅ Связка найдена'; + } else { + document.getElementById('link-status').textContent = '❌ Грунтовая связка не найдена'; + } + } catch(e) { + document.getElementById('link-status').textContent = '❌ ' + e.message; + } +} + +function renderLinkCards(routes) { + const map = window._map; + const colors = ['#0066ff', '#00aa44', '#9933cc']; + const cardsEl = document.getElementById('link-cards'); + cardsEl.innerHTML = ''; + + routes.forEach((r, i) => { + const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; + const sid = `link-src-${i}`; + const lid = `link-line-${i}`; + if (map.getSource(sid)) map.removeSource(sid); + if (map.getLayer(lid)) map.removeLayer(lid); + map.addSource(sid, { type: 'geojson', data: geojson }); + map.addLayer({ + id: lid, type: 'line', source: sid, + paint: { + 'line-color': colors[i % colors.length], + 'line-width': i === 0 ? 5 : 3, + 'line-opacity': i === 0 ? 0.9 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + const km = (r.distance_m / 1000).toFixed(0); + const time = formatDuration(r.duration_s); + const dirt = r.stats?.dirt_total_pct || '?'; + const col = colors[i % colors.length]; + const card = document.createElement('div'); + card.className = 'route-card' + (i === 0 ? ' active' : ''); + card.innerHTML = ` +
+ + Вариант ${i+1} + ${km} км + ${time} +
+
${dirt}% грунт
+ `; + card.onclick = () => selectLinkRoute(i); + cardsEl.appendChild(card); + }); +} + +function selectLinkRoute(idx) { + const map = window._map; + document.querySelectorAll('#link-cards .route-card').forEach((c, i) => c.classList.toggle('active', i === idx)); + // Highlight selected route + for (let i = 0; i < 3; i++) { + const lid = `link-line-${i}`; + if (map.getLayer(lid)) { + map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); + map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); + } + } +} + +function clearLink() { + linkPoints = []; + linkMarkers.forEach(m => m.remove()); + linkMarkers = []; + const map = window._map; + for (let i = 0; i < 5; i++) { + const lid = `link-line-${i}`; + if (map.getLayer(lid)) map.removeLayer(lid); + const sid = `link-src-${i}`; + if (map.getSource(sid)) map.removeSource(sid); + } + closeSheet('sheet-link'); + document.getElementById('link-cards').innerHTML = ''; +} + +// ─── Фаза 4: Красивый маршрут ──────────────────────────────────────────────── +let scenicMode = false; +let scenicStart = null; +let scenicStartMarker = null; +let scenicTargetKm = 100; +let scenicRoutes = []; +let activeScenicIdx = 0; + +function toggleScenicMode() { + scenicMode = !scenicMode; + const btn = document.getElementById('tb-scenic'); + if (scenicMode) { + deactivateAllModes(); + scenicMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + openSheet('sheet-scenic'); + document.getElementById('scenic-status').textContent = 'Кликни точку старта на карте'; + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + clearScenic(); + } +} + +function setScenicKm(km) { + scenicTargetKm = km; + document.querySelectorAll('.scenic-km-btn').forEach(b => { + b.classList.toggle('active', +b.dataset.km === km); + }); + const inp = document.getElementById('scenic-custom-km'); + if (inp) inp.value = km; +} + +async function buildScenicRoute() { + if (!scenicStart) return; + const map = window._map; + document.getElementById('scenic-status').textContent = '⏳ Строю красивый маршрут...'; + document.getElementById('btn-build-scenic').textContent = '⏳ Строю...'; + document.getElementById('btn-build-scenic').disabled = true; + + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + try { + const resp = await fetch(`${basePath}/api/scenic`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lon: scenicStart.lon, lat: scenicStart.lat, target_km: scenicTargetKm }) + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || 'Ошибка'); + } + const data = await resp.json(); + scenicRoutes = data.routes || []; + + if (scenicRoutes.length === 0) throw new Error('Маршрут не найден'); + + // Draw routes on map + const colors = ['#0066ff', '#00aa44', '#9933cc']; + scenicRoutes.forEach((r, i) => { + const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; + const sid = `scenic-src-${i}`; + const lid = `scenic-line-${i}`; + if (map.getLayer(lid)) map.removeLayer(lid); + if (map.getSource(sid)) map.removeSource(sid); + map.addSource(sid, { type: 'geojson', data: geojson }); + map.addLayer({ + id: lid, type: 'line', source: sid, + paint: { + 'line-color': colors[i % colors.length], + 'line-width': i === activeScenicIdx ? 5 : 3, + 'line-opacity': i === activeScenicIdx ? 0.9 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + }); + + // Show cards + const cardsEl = document.getElementById('scenic-cards'); + if (cardsEl) { + cardsEl.innerHTML = scenicRoutes.map((r, i) => { + const col = colors[i % colors.length]; + const km = (r.distance_m / 1000).toFixed(0); + const time = formatDuration(r.duration_s); + const dirt = r.stats?.dirt_total_pct || '?'; + const pois = (r.scenic_pois || []).map(p => { + const SCENIC_LABELS = {'natural=water':'💧 Озёро','tourism=viewpoint':'👁 Смотровая','historic=ruins':'🏚 Руины','natural=peak':'🔺 Вершина','natural=cave_entrance':'🕳 Пещера','ford=yes':'🌊 Брод'}; + const label = SCENIC_LABELS[p.type] || '📍 ' + p.type; + const name = p.name ? ` — ${p.name}` : ''; + return `
${label}${name}
`; + }).join(''); + return `
+
+ + ${r.name || 'Вариант '+(i+1)} + ${km} км + ${time} +
+
${dirt}% грунт · score=${r.scenic_score||0}
+ ${pois ? '
'+pois+'
' : ''} +
`; + }).join(''); + } + + document.getElementById('scenic-status').textContent = `✅ ${scenicRoutes.length} маршрут(ов)`; + } catch(e) { + document.getElementById('scenic-status').textContent = '❌ ' + e.message; + } + document.getElementById('btn-build-scenic').textContent = '🎨 Построить маршрут'; + document.getElementById('btn-build-scenic').disabled = false; +} + +function selectScenicRoute(idx) { + activeScenicIdx = idx; + const map = window._map; + scenicRoutes.forEach((_, i) => { + const lid = `scenic-line-${i}`; + if (map.getLayer(lid)) { + map.setLayoutProperty(lid, 'visibility', 'visible'); + map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); + map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); + } + }); + document.querySelectorAll('#scenic-cards .route-card').forEach((c, i) => { + c.classList.toggle('active', i === idx); + }); +} + +function clearScenic() { + const map = window._map; + scenicRoutes.forEach((_, i) => { + const lid = `scenic-line-${i}`; + const sid = `scenic-src-${i}`; + if (map.getLayer(lid)) map.removeLayer(lid); + if (map.getSource(sid)) map.removeSource(sid); + }); + if (scenicStartMarker) { scenicStartMarker.remove(); scenicStartMarker = null; } + scenicStart = null; + scenicRoutes = []; + closeSheet('sheet-scenic'); + const cardsEl = document.getElementById('scenic-cards'); + if (cardsEl) cardsEl.innerHTML = ''; +} + +initMap();