// ═══════════════════════════════════════════════════════════════════ // Enduro Trails - Phase 5 Redesign // Theme system (auto/light/dark + SunCalc), skeleton, swipe, animations // ═══════════════════════════════════════════════════════════════════ // ─── Theme System ────────────────────────────────────────────────── let themeMode = localStorage.getItem('enduro-theme-mode') || 'auto'; // 'auto' | 'light' | 'dark' let userLat = null; let userLon = null; let themeAutoInterval = null; function isDarkTheme() { return document.body.classList.contains('theme-dark'); } function applyTheme() { if (themeMode === 'light') { document.body.className = 'theme-light'; } else if (themeMode === 'dark') { document.body.className = 'theme-dark'; } else { // auto: use SunCalc applyAutoTheme(); } updateThemeButtonIcon(); switchMapStyle(); } function applyAutoTheme() { if (themeMode !== 'auto') return; const now = new Date(); const lat = userLat || 55.75; const lon = userLon || 37.62; let isDay = true; try { if (typeof SunCalc !== 'undefined') { const times = SunCalc.getTimes(now, lat, lon); isDay = now >= times.sunrise && now < times.sunset; } else { // Fallback: assume day if 6am-8pm const h = now.getHours(); isDay = h >= 6 && h < 20; } } catch(e) { const h = now.getHours(); isDay = h >= 6 && h < 20; } document.body.className = isDay ? 'theme-light' : 'theme-dark'; updateThemeButtonIcon(); } function toggleTheme() { // Cycle: auto → light → dark → auto if (themeMode === 'auto') themeMode = 'light'; else if (themeMode === 'light') themeMode = 'dark'; else themeMode = 'auto'; localStorage.setItem('enduro-theme-mode', themeMode); applyTheme(); } function updateThemeButtonIcon() { const sunIcon = document.getElementById('theme-icon-sun'); const moonIcon = document.getElementById('theme-icon-moon'); const label = document.getElementById('theme-label'); if (!sunIcon || !moonIcon) return; const dark = isDarkTheme(); if (themeMode === 'auto') { // Dynamic icon based on actual theme sunIcon.style.display = dark ? 'none' : 'block'; moonIcon.style.display = dark ? 'block' : 'none'; if (label) label.textContent = 'Авто'; } else if (themeMode === 'light') { sunIcon.style.display = 'block'; moonIcon.style.display = 'none'; if (label) label.textContent = 'День'; } else { sunIcon.style.display = 'none'; moonIcon.style.display = 'block'; if (label) label.textContent = 'Ночь'; } } function switchMapStyle() { const map = window._map; if (!map) return; const dark = isDarkTheme(); const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; const tileBase = window.location.origin + basePath; const styleUrl = dark ? basePath + '/style-dark.json' : basePath + '/style.json'; // Save current position before style change const center = map.getCenter(); const zoom = map.getZoom(); const bearing = map.getBearing(); const pitch = map.getPitch(); fetch(styleUrl).then(r => { if (r.ok) return r.json(); throw new Error('Style not available'); }).then(style => { // Fix tile URLs to absolute (same as initMap) if (style.sources && style.sources['trails-tiles'] && style.sources['trails-tiles'].tiles) { style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`]; } map.setStyle(style); // Restore position and overlays after style loads map.once('idle', () => { map.jumpTo({ center, zoom, bearing, pitch }); rebuildMapOverlays(); }); }).catch(() => { // Network error or style not available, 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() { // ET-007: восстановить выбранную подложку первой — чтобы terrain/trails/POI // оказались поверх неё (см. ADR-004, TRZ §5.5). if (typeof restoreBaseLayerState === 'function') { restoreBaseLayerState(); } // Re-apply terrain and trails after style change restoreTerrainState(); restoreTrailsState(); restorePoiState(); // ET-008: публичные GPS-треки if (typeof restorePublicTracksState === 'function') { restorePublicTracksState(); } // 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(); // ET-006: восстановить GPX-слои после смены стиля карты (ADR-002, REQ-F-13) if (typeof rebuildGpxOverlays === 'function') rebuildGpxOverlays(); } // ─── Utilities ────────────────────────────────────────────────────── function formatDuration(seconds) { const totalMin = Math.round(seconds / 60); if (totalMin < 60) return totalMin + ' мин'; const days = Math.floor(totalMin / 1440); const hours = Math.floor((totalMin % 1440) / 60); const mins = totalMin % 60; if (days > 0) { if (hours === 0 && mins === 0) return `${days} дн`; if (mins === 0) return `${days} дн ${hours} ч`; return `${days} дн ${hours} ч ${mins} мин`; } if (mins === 0) return `${hours} ч`; return `${hours} ч ${mins} мин`; } // ET-005: форматирование расстояний централизовано в units.js (ADR-0001). function formatDist(m) { if (!m) return '-'; return Units.formatDistance(m); } // ─── Sheet Management ────────────────────────────────────────────── function openSheet(id) { const sheet = document.getElementById(id); if (!sheet) return; // Close all other sheets first document.querySelectorAll('.bottom-sheet.open').forEach(s => { if (s.id !== id) closeSheet(s.id); }); sheet.classList.add('open'); const backdrop = document.getElementById('sheet-backdrop'); backdrop.classList.add('visible'); } function closeSheet(id) { const sheet = document.getElementById(id); if (!sheet) return; sheet.classList.remove('open'); sheet.style.transform = ''; // Check if any sheets still open const anyOpen = document.querySelector('.bottom-sheet.open'); if (!anyOpen) { document.getElementById('sheet-backdrop').classList.remove('visible'); } } // Close sheet panel but keep the mode active (route stays on map) function minimizeSheet(id) { closeSheet(id); if (id === 'sheet-route' && routeResults.length > 0) { showMiniRouteSheet(); } } function closeAllSheets() { document.querySelectorAll('.bottom-sheet.open').forEach(s => { s.classList.remove('open'); s.style.transform = ''; }); document.getElementById('sheet-backdrop').classList.remove('visible'); } // ─── Swipe-down to close sheets ──────────────────────────────────── function initSheetSwipe() { document.querySelectorAll('.bottom-sheet').forEach(sheet => { let startY = 0; let isDragging = false; sheet.addEventListener('touchstart', (e) => { const rect = sheet.getBoundingClientRect(); const touchY = e.touches[0].clientY; // Only initiate swipe from the handle area (top 50px of sheet) if (touchY < rect.top + 50 || e.target.closest('.sheet-handle')) { isDragging = true; startY = touchY; sheet.classList.add('swiping'); } }, { passive: true }); sheet.addEventListener('touchmove', (e) => { if (!isDragging) return; const dy = e.touches[0].clientY - startY; if (dy > 0) { sheet.style.transform = `translateY(${dy}px)`; } }, { passive: true }); sheet.addEventListener('touchend', (e) => { if (!isDragging) return; isDragging = false; sheet.classList.remove('swiping'); const dy = e.changedTouches[0].clientY - startY; if (dy > 80) { const sheetId = sheet.id; if (sheetId === 'sheet-route' && routeResults && routeResults.length > 0) { minimizeSheet(sheetId); } else { closeSheet(sheetId); // Deactivate corresponding mode if (sheetId === 'sheet-route' && routeMode) toggleRouteMode(); else if (sheetId === 'sheet-recon' && reconMode) toggleReconMode(); else if (sheetId === 'sheet-scenic' && scenicMode) toggleScenicMode(); else if (sheetId === 'sheet-link' && linkMode) toggleLinkMode(); } sheet.style.transform = ''; } else { sheet.style.transform = ''; } }, { passive: true }); }); } // ─── Skeleton Loading ────────────────────────────────────────────── function showSkeleton(containerId, count) { const container = document.getElementById(containerId); if (!container) return; count = count || 2; let html = ''; for (let i = 0; i < count; i++) { html += `
`; } container.innerHTML = html; } // ─── Deactivate All Modes ────────────────────────────────────────── function deactivateAllModes() { // Deactivate all input modes but preserve route/scenic/link data on map if (routeMode) { routeMode = false; document.getElementById('tb-route').classList.remove('active'); closeSheet('sheet-route'); /* NOT clearRoute - keep line on map */ } if (rulerMode) toggleRuler(); if (markerMode) toggleMarkerMode(); if (typeof reconMode !== 'undefined' && reconMode) toggleReconMode(); if (typeof linkMode !== 'undefined' && linkMode) toggleLinkMode(); if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); if (window._map) window._map.getCanvas().style.cursor = ''; updateMapModeClass(); } function updateMapModeClass() { const has = routeMode || rulerMode || markerMode || reconMode || linkMode || scenicMode; document.body.classList.toggle('has-map-mode', !!has); } // ─── Компас ──────────────────────────────────────────────────────── let compassLocked = false; function toggleCompass() { const map = window._map; if (!map) return; const btn = document.getElementById('btn-compass'); compassLocked = !compassLocked; if (compassLocked) { map.rotateTo(0, { duration: 300 }); map.dragRotate.disable(); map.touchZoomRotate.disableRotation(); btn.classList.add('active'); } else { map.dragRotate.enable(); map.touchZoomRotate.enableRotation(); btn.classList.remove('active'); } } // ─── Геолокация ──────────────────────────────────────────────────── let locationMarker = null; function locateMe() { if (!navigator.geolocation) { alert('Геолокация недоступна в этом браузере'); return; } navigator.geolocation.getCurrentPosition( (pos) => { const { longitude, latitude } = pos.coords; const map = window._map; userLat = latitude; userLon = longitude; map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 }); if (locationMarker) { locationMarker.setLngLat([longitude, latitude]); } else { const el = document.createElement('div'); el.className = 'my-location-marker'; el.innerHTML = '
'; locationMarker = new maplibregl.Marker({ element: el, anchor: 'center' }) .setLngLat([longitude, latitude]) .addTo(map); } // If in auto theme mode, recalculate with real coordinates if (themeMode === 'auto') applyAutoTheme(); }, (err) => { alert('Не удалось определить местоположение: ' + err.message); }, { enableHighAccuracy: true, timeout: 10000 } ); } // ─── Layer visibility state ──────────────────────────────────────── const layerState = { tracks: true, paths: true, poi: true, basemap: true }; const layerGroups = { tracks: ['trails-track', 'trails-asphalt'], paths: ['trails-path-bridleway'], poi: ['poi-circles', 'poi-labels'], basemap: ['osm-base'], }; function toggleLayer(group) { layerState[group] = !layerState[group]; const btn = document.getElementById('btn-' + group); btn.classList.toggle('active', layerState[group]); const visibility = layerState[group] ? 'visible' : 'none'; layerGroups[group].forEach(id => { if (window._map && window._map.getLayer(id)) { window._map.setLayoutProperty(id, 'visibility', visibility); } }); } // ─── Роутинг - состояние ─────────────────────────────────────────── const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; let routeMode = false; let routeWaypoints = []; let routeResults = []; let activeRouteIdx = 0; let waypointMarkers = []; let addingWaypoint = false; let buildDebounceTimer = null; function getBasePath() { return window.location.pathname.replace(/\/[^/]*$/, '') || ''; } // ─── Режим маршрута ──────────────────────────────────────────────── function toggleRouteMode() { const btn = document.getElementById('tb-route'); if (routeMode) { // If sheet is open - close sheet but stay in mode const sheet = document.getElementById('sheet-route'); if (sheet && sheet.classList.contains('open')) { closeSheet('sheet-route'); return; } // Sheet is closed - exit mode and clear route routeMode = false; btn.classList.remove('active'); clearRoute(); window._map.getCanvas().style.cursor = ''; } else { // Enter route mode - show onboarding mini-bar instead of full sheet deactivateAllModes(); routeMode = true; btn.classList.add('active'); clearRoute(); window._map.getCanvas().style.cursor = 'crosshair'; showRouteOnboardingMini(); } updateMapModeClass(); } function clearRoute() { hideMiniRouteSheet(); waypointMarkers.forEach(m => m.remove()); waypointMarkers = []; routeWaypoints = []; routeResults = []; activeRouteIdx = 0; addingWaypoint = false; const map = window._map; if (map) { for (let i = 0; i < 5; i++) { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); if (map.getSource('route-' + i)) map.removeSource('route-' + i); } } document.getElementById('route-status').textContent = 'Тапни точку старта на карте'; document.getElementById('route-cards').innerHTML = ''; document.getElementById('waypoints-list').innerHTML = ''; if (routeMode && map) map.getCanvas().style.cursor = 'crosshair'; hideMiniOnboard(); } function resetRouteFromSheet() { clearRoute(); closeSheet('sheet-route'); if (routeMode) showRouteOnboardingMini(); } window.resetRouteFromSheet = resetRouteFromSheet; function addWaypointMode() { if (routeWaypoints.length >= 10) return; if (!routeMode) { routeMode = true; document.getElementById('tb-route').classList.add('active'); updateMapModeClass(); } addingWaypoint = true; window._map.getCanvas().style.cursor = 'crosshair'; // Hide main sheet so the map is visible for tapping closeSheet('sheet-route'); // Show onboarding mini-bar for "add waypoint" mode _showMiniOnboardWaypoint(); } function _showMiniOnboardWaypoint() { // Reuse mini-onboard UI with "add waypoint" hint const idx = routeWaypoints.length; // next waypoint index const label = String(idx); const color = '#0066ff'; const hint = 'Тапни на карте — добавить точку'; document.getElementById('mini-onboard').style.display = 'flex'; document.getElementById('mini-dot').style.display = 'none'; document.getElementById('mini-label').style.display = 'none'; document.getElementById('mini-stats').style.display = 'none'; document.getElementById('mini-wheel').style.display = 'none'; const arrows = document.querySelector('.mini-route-arrows'); if (arrows) arrows.style.display = 'none'; const addBtn = document.getElementById('mini-add-btn'); if (addBtn) addBtn.style.display = 'none'; document.getElementById('mini-onboard-pin').innerHTML = waypointPinSvg(label, color); document.getElementById('mini-onboard-hint').textContent = hint; document.getElementById('sheet-route-mini').classList.add('visible'); const ctrl = document.getElementById('map-controls-r'); if (ctrl) ctrl.style.bottom = '148px'; // Search button const searchBtn = document.getElementById('mini-onboard-search-btn'); searchBtn.onclick = () => toggleMiniOnboardSearch('waypoint'); // Показать кнопку отмены const cancelBtn = document.getElementById('mini-onboard-cancel-btn'); if (cancelBtn) { cancelBtn.style.display = 'inline-flex'; cancelBtn.onclick = cancelAddWaypoint; } } // ─── Маркеры точек ───────────────────────────────────────────────── function createWaypointMarkerEl(index, total) { const el = document.createElement('div'); el.className = 'route-waypoint-marker'; el.style.animation = 'none'; let bg, label; if (index === 0) { bg = '#2EA043'; label = 'S'; } else if (index === total - 1) { bg = '#FF3B1F'; label = 'F'; } else { bg = '#0066ff'; label = String(index); } if (label === 'F') { const uid = Math.random().toString(36).slice(2); el.innerHTML = ` F `; } else { el.innerHTML = ` ${label} `; } return el; } function rebuildWaypointMarkers() { waypointMarkers.forEach(m => m.remove()); waypointMarkers = []; const map = window._map; routeWaypoints.forEach((wp, i) => { const el = createWaypointMarkerEl(i, routeWaypoints.length); const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', draggable: true }) .setLngLat([wp.lon, wp.lat]) .addTo(map); (function(idx) { marker.on('dragend', () => { const lngLat = marker.getLngLat(); routeWaypoints[idx] = { lon: lngLat.lng, lat: lngLat.lat }; renderWaypointsList(); debounceBuildRoute(); }); })(i); waypointMarkers.push(marker); }); } // ─── Reverse Geocoding ─────────────────────────────────────────── const geocodeCache = {}; async function reverseGeocode(lat, lon) { const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; if (geocodeCache[key]) return geocodeCache[key]; try { const resp = await fetch( `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&accept-language=ru`, { headers: { 'Accept-Language': 'ru' } } ); const data = await resp.json(); const a = data.address || {}; const name = a.village || a.hamlet || a.town || a.city || a.suburb || a.road || a.county || a.state || `${lat.toFixed(3)}, ${lon.toFixed(3)}`; geocodeCache[key] = name; return name; } catch(e) { return `${lat.toFixed(3)}, ${lon.toFixed(3)}`; } } function waypointPinSvg(label, color) { const fs = label.length > 1 ? '7' : '9'; // Finish flag — checkered pattern if (label === 'F') { const uid = Math.random().toString(36).slice(2); return ` ${label} `; } return ` ${label} `; } function haversineM(a, b) { const R = 6371000; const dLat = (b.lat - a.lat) * Math.PI / 180; const dLon = (b.lon - a.lon) * Math.PI / 180; const s = Math.sin(dLat/2)**2 + Math.cos(a.lat*Math.PI/180) * Math.cos(b.lat*Math.PI/180) * Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); } // ET-005: единица и разделитель определяются units.js (ADR-0001, риск R4). function formatSegmentDist(m) { return Units.formatDistance(m); } // Returns array of route-distance segments (meters) for each waypoint. // segDists[0] = 0, segDists[i] = distance along route geometry from wp[i-1] to wp[i]. // First waypoint snaps to geometry[0], last to geometry[n-1] — guarantees sum == full route length. function getRouteSegmentDistances() { const route = routeResults[activeRouteIdx]; if (!route || !route.geometry || !route.geometry.coordinates) return null; const coords = route.geometry.coordinates; // [[lon, lat], ...] const n = coords.length; if (n < 2 || routeWaypoints.length < 2) return null; // Convert geometry coords to {lat, lon} for haversineM const geoPts = coords.map(([lon, lat]) => ({ lat, lon })); // Snap each waypoint to nearest geometry index const snapIdx = routeWaypoints.map(wp => { let bestIdx = 0, bestDist = Infinity; for (let j = 0; j < n; j++) { const d = haversineM(wp, geoPts[j]); if (d < bestDist) { bestDist = d; bestIdx = j; } } return bestIdx; }); // Force first waypoint → geometry start, last waypoint → geometry end // This ensures segments sum exactly to the full route geometry length snapIdx[0] = 0; snapIdx[snapIdx.length - 1] = n - 1; // For each segment i→i+1, sum haversine along geometry points const segDists = [0]; for (let i = 1; i < routeWaypoints.length; i++) { const from = snapIdx[i - 1]; const to = snapIdx[i]; if (from === to) { segDists.push(haversineM(routeWaypoints[i - 1], routeWaypoints[i])); continue; } const step = to > from ? 1 : -1; let dist = 0; for (let j = from; j !== to; j += step) { dist += haversineM(geoPts[j], geoPts[j + step]); } segDists.push(dist); } // Scale so sum of segments == route.distance_m exactly const rawTotal = segDists.slice(1).reduce((a, b) => a + b, 0); if (rawTotal > 0 && route.distance_m > 0) { const scale = route.distance_m / rawTotal; for (let i = 1; i < segDists.length; i++) segDists[i] = Math.round(segDists[i] * scale); } return segDists; } async function renderWaypointsList() { const list = document.getElementById('waypoints-list'); // ── Onboarding: no waypoints yet ────────────────────────────── if (!routeWaypoints.length) { list.innerHTML = `
${waypointPinSvg('S', '#2EA043')}
или тапни на карте
`; _initOnboardSearch('start'); _initWaypointDragHandles(list); return; } // ── Onboarding: only start added, need finish ────────────────── // (handled below after normal list render) const gripSvg = ``; const segDists = (routeResults.length > 0 && activeRouteIdx >= 0) ? getRouteSegmentDistances() : null; let html = routeWaypoints.map((wp, i) => { const isStart = i === 0; const isEnd = i === routeWaypoints.length - 1; const label = isStart ? 'S' : isEnd ? 'F' : String(i); const color = isStart ? '#2EA043' : isEnd ? '#FF3B1F' : '#0066ff'; const coordText = `${wp.lat.toFixed(3)}, ${wp.lon.toFixed(3)}`; const distStr = i > 0 && segDists ? formatSegmentDist(segDists[i]) : i > 0 ? formatSegmentDist(haversineM(routeWaypoints[i-1], wp)) : ''; return `
${waypointPinSvg(label, color)}
${coordText} ${distStr ? `${distStr}` : ''}
${gripSvg}
`; }).join(''); // Кнопка «Добавить точку» в стиле wl-item if (routeWaypoints.length < 10) { html += `
${waypointPinSvg('+', 'var(--text3)')}
Добавить точку
`; } // Onboarding finish field: only start added, no finish yet if (routeWaypoints.length === 1) { html += `
${waypointPinSvg('F', '#FF3B1F')}
или тапни на карте
`; } list.innerHTML = html; // Init finish onboard search if only 1 waypoint if (routeWaypoints.length === 1) { _initOnboardSearch('finish'); } // Async geocode routeWaypoints.forEach(async (wp, i) => { const name = await reverseGeocode(wp.lat, wp.lon); const el = document.getElementById(`wl-label-${i}`); if (el) el.textContent = name; }); // Touch drag-and-drop (mobile only) _initWaypointDragHandles(list); } // ─── Onboard search helpers ──────────────────────────────────────── function _initOnboardSearch(type) { const input = document.getElementById(`wl-onboard-input-${type}`); const resultsEl = document.getElementById(`wl-onboard-results-${type}`); if (!input) return; let timeout = null; input.addEventListener('input', () => { clearTimeout(timeout); const q = input.value.trim(); if (q.length < 2) { resultsEl.innerHTML = ''; return; } timeout = setTimeout(() => _doOnboardSearch(type, q, resultsEl), 400); }); // Autofocus only for start field (finish field appears inline) if (type === 'start') { setTimeout(() => input.focus(), 100); } } async function _doOnboardSearch(type, query, resultsEl) { resultsEl.innerHTML = '
Поиск...
'; try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&countrycodes=ru&accept-language=ru`; const resp = await fetch(url); const data = await resp.json(); if (!data.length) { resultsEl.innerHTML = '
Ничего не найдено
'; return; } resultsEl.innerHTML = data.map(item => { const parts = (item.display_name || '').split(', '); const name = parts[0]; const sub = parts.slice(1, 3).join(', '); return `
${name}
${sub ? `
${sub}
` : ''}
`; }).join(''); } catch(e) { resultsEl.innerHTML = '
Ошибка
'; } } function _selectOnboardResult(type, lat, lon, name) { const wp = { lat: parseFloat(lat), lon: parseFloat(lon) }; if (type === 'start') { routeWaypoints.unshift(wp); } else { routeWaypoints.push(wp); } rebuildWaypointMarkers(); renderWaypointsList(); window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 12, duration: 600 }); if (routeWaypoints.length >= 2) debounceBuildRoute(); updateMiniRouteCard(); } function _initWaypointDragHandles(list) { let dragIdx = -1; let startY = 0; let dragging = false; let lastOverEl = null; let lastOverPos = null; function getItemEls() { return Array.from(list.querySelectorAll('.wl-item[data-idx]')); } function clearHighlights() { getItemEls().forEach(el => { el.classList.remove('drag-over-top', 'drag-over-bottom', 'dragging'); }); } function getDropTarget(clientY) { const items = getItemEls(); for (const el of items) { const idx = parseInt(el.dataset.idx, 10); if (idx === dragIdx) continue; const rect = el.getBoundingClientRect(); if (clientY >= rect.top && clientY <= rect.bottom) { const mid = rect.top + rect.height / 2; return { el, idx, pos: clientY < mid ? 'top' : 'bottom' }; } } return null; } function startDrag(clientY, idx) { dragIdx = idx; startY = clientY; dragging = false; lastOverEl = null; lastOverPos = null; const dragEl = document.getElementById(`wl-item-${idx}`); if (dragEl) dragEl.classList.add('dragging'); } function moveDrag(clientY) { if (dragIdx < 0) return; const dy = Math.abs(clientY - startY); if (dy > 5) dragging = true; if (!dragging) return; clearHighlights(); const target = getDropTarget(clientY); if (target) { lastOverEl = target.el; lastOverPos = target.pos; target.el.classList.add(target.pos === 'top' ? 'drag-over-top' : 'drag-over-bottom'); } else { lastOverEl = null; lastOverPos = null; } } function endDrag(finalClientY) { if (dragIdx < 0) return; clearHighlights(); const dy = Math.abs(finalClientY - startY); if (dragging && dy > 30 && lastOverEl !== null) { const dropIdx = parseInt(lastOverEl.dataset.idx, 10); let insertAt = lastOverPos === 'top' ? dropIdx : dropIdx + 1; const moved = routeWaypoints.splice(dragIdx, 1)[0]; if (insertAt > dragIdx) insertAt--; routeWaypoints.splice(insertAt, 0, moved); rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); updateMiniRouteCard(); } dragIdx = -1; dragging = false; lastOverEl = null; lastOverPos = null; } // Touch (mobile) list.addEventListener('touchstart', (e) => { const handle = e.target.closest('.wl-drag-handle'); if (!handle) return; startDrag(e.touches[0].clientY, parseInt(handle.dataset.idx, 10)); }, { passive: true }); list.addEventListener('touchmove', (e) => { if (dragIdx < 0) return; moveDrag(e.touches[0].clientY); e.preventDefault(); }, { passive: false }); list.addEventListener('touchend', (e) => { endDrag(e.changedTouches[0].clientY); }, { passive: true }); // Mouse (desktop) list.addEventListener('mousedown', (e) => { const handle = e.target.closest('.wl-drag-handle'); if (!handle) return; if (e.target.closest('.wl-add')) return; // не перехватывать кнопку добавления e.preventDefault(); startDrag(e.clientY, parseInt(handle.dataset.idx, 10)); document.addEventListener('mousemove', _onDragMouse); document.addEventListener('mouseup', _onDropMouse); }); function _onDragMouse(e) { if (dragIdx < 0) return; moveDrag(e.clientY); } function _onDropMouse(e) { endDrag(e.clientY); document.removeEventListener('mousemove', _onDragMouse); document.removeEventListener('mouseup', _onDropMouse); } } function removeWaypoint(idx) { routeWaypoints.splice(idx, 1); rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) { debounceBuildRoute(); } else { const map = window._map; for (let i = 0; i < 5; i++) { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); if (map.getSource('route-' + i)) map.removeSource('route-' + i); } routeResults = []; document.getElementById('route-cards').innerHTML = ''; document.getElementById('route-status').textContent = routeWaypoints.length === 0 ? 'Тапни точку старта на карте' : routeWaypoints.length === 1 ? 'Тапни точку финиша' : ''; } } // ─── Построение маршрута ─────────────────────────────────────────── function debounceBuildRoute() { clearTimeout(buildDebounceTimer); buildDebounceTimer = setTimeout(buildRoute, 300); } async function buildRoute() { if (routeWaypoints.length < 2) return; const map = window._map; const basePath = getBasePath(); // Show mini-bar with spinning wheel showMiniRouteLoading(); showRouteLoading(); // Close main sheet if open closeSheet('sheet-route'); document.getElementById('route-status').textContent = 'Строю маршрут...'; showSkeleton('route-cards', 3); try { const resp = await fetch(basePath + '/api/route', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ waypoints: routeWaypoints, alternatives: 5 }), }); if (!resp.ok) throw new Error('Маршрут не найден'); const data = await resp.json(); routeResults = data.routes || []; if (!routeResults.length) throw new Error('Маршрут не найден'); drawRouteResults(routeResults, 0); renderWaypointsList(); // update segment distances now that route is built document.getElementById('route-status').textContent = `${routeResults.length} маршрут(ов)`; // Show mini-bar with result - do NOT open main sheet hideMiniRouteLoading(); showMiniRouteSheet(); } catch(e) { hideMiniRouteLoading(); document.getElementById('route-status').textContent = '❌ ' + e.message; document.getElementById('route-cards').innerHTML = ''; const statsEl = document.getElementById('mini-stats'); if (statsEl) statsEl.textContent = '❌ ' + e.message; } } function drawRouteResults(routes, activeIdx) { const map = window._map; activeRouteIdx = activeIdx; const wasBuilt = routeResults.length > 0; // track rebuild vs first build routeResults = routes; // Clear old layers for (let i = 0; i < 5; i++) { try { if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); } catch(e) {} try { if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); } catch(e) {} try { if (map.getSource('route-' + i)) map.removeSource('route-' + i); } catch(e) {} } routes.forEach((route, i) => { const color = ROUTE_COLORS[i] || '#888888'; const isActive = i === activeIdx; map.addSource('route-' + i, { type: 'geojson', data: { type: 'Feature', geometry: route.geometry, properties: {} } }); map.addLayer({ id: 'route-line-' + i + '-outline', type: 'line', source: 'route-' + i, paint: { 'line-color': '#ffffff', 'line-width': isActive ? 7 : 4, 'line-opacity': isActive ? 0.6 : 0, }, layout: { 'line-cap': 'round', 'line-join': 'round' } }); map.addLayer({ id: 'route-line-' + i, type: 'line', source: 'route-' + i, paint: { 'line-color': color, 'line-width': isActive ? 5 : 3, 'line-opacity': isActive ? 0.95 : 0.5, }, layout: { 'line-cap': 'round', 'line-join': 'round' } }); map.on('click', 'route-line-' + i, (e) => { if (e.stopPropagation) e.stopPropagation(); selectRoute(i); }); map.on('mouseenter', 'route-line-' + i, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'route-line-' + i, () => { map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; }); }); renderRouteCards(routes); // Auto-zoom to active route after drawing const activeRoute = routes[activeIdx] || routes[0]; if (activeRoute && activeRoute.geometry && activeRoute.geometry.coordinates) { const coords = activeRoute.geometry.coordinates; if (coords.length > 1) { const bounds = coords.reduce( (b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]) ); map.fitBounds(bounds, { padding: { top: 80, bottom: 160, left: 20, right: 20 }, duration: 1200, maxZoom: 14 }); } } // Update mini sheet if visible const miniEl = document.getElementById('sheet-route-mini'); if (miniEl && miniEl.classList.contains('visible')) showMiniRouteSheet(); // Auto-minimize sheet on rebuild (not on first build) if (wasBuilt) { const sheet = document.getElementById('sheet-route'); if (sheet && sheet.classList.contains('open')) { minimizeSheet('sheet-route'); } } } function selectRoute(idx) { activeRouteIdx = idx; const map = window._map; routeResults.forEach((_, i) => { const isActive = i === idx; try { if (map.getLayer('route-line-' + i)) { map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5); } if (map.getLayer('route-line-' + i + '-outline')) { map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4); map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0); } } catch(e) {} }); document.querySelectorAll('.route-card').forEach((card, i) => { card.classList.toggle('active', i === idx); }); renderWaypointsList(); } // ─── Карточки маршрутов ─────────────────────────────────────────── function renderRouteCards(routes) { const container = document.getElementById('route-cards'); container.innerHTML = routes.map((route, i) => { const color = ROUTE_COLORS[i] || '#888888'; const timeStr = formatDuration(route.duration_s); const isActive = i === activeRouteIdx; const s = route.stats || {}; const dirtPct = s.dirt_total_pct || 0; const asphPct = s.asphalt_pct || 0; return `
Вариант ${i + 1} ${Units.formatDistance(route.distance_m)} · ${timeStr}
${dirtPct}% грунт${asphPct ? ` · ${asphPct}% асфальт` : ''}
`; }).join(''); } // ─── GPX экспорт ─────────────────────────────────────────────────── function escapeXml(str) { return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function generateGPX() { const route = routeResults[activeRouteIdx]; if (!route) return ''; const now = new Date(); const dateStr = now.toISOString().slice(0, 10); // ET-005: экспорт GPX остаётся метрическим — стандарт GPX и риск R6 // исключают конвертацию выгружаемых данных в мили. const distKm = (route.distance_m / 1000).toFixed(1); const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; const wpts = routeWaypoints.map((wp, i) => { const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`; return ` ${escapeXml(name)}`; }); const markers = loadMarkers(); markers.forEach(m => { wpts.push(` ${escapeXml(m.name)}${escapeXml(m.icon)}`); }); const trkpts = route.geometry.coordinates.map(([lon, lat]) => ` ` ).join('\n'); return ` Enduro route ${dateStr} ${distKm} км · ${dirtPct}% грунт ${wpts.join('\n')} Enduro route ${dateStr} ${trkpts} `; } function downloadGPX() { const gpx = generateGPX(); if (!gpx) return; const now = new Date(); const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15); const filename = `enduro-${timeStr}.gpx`; const blob = new Blob([gpx], { type: 'application/gpx+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } // ─── Флажки / именованные метки ──────────────────────────────────── const MARKER_ICONS = ['🚩', '⛺', '🔧', '⛽', '💧', '📍']; const MARKERS_KEY = 'enduro_markers'; let markerMode = false; let namedMarkerObjects = {}; function loadMarkers() { try { return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]'); } catch(e) { return []; } } function saveMarkers(markers) { try { localStorage.setItem(MARKERS_KEY, JSON.stringify(markers)); } catch(e) {} } function toggleMarkerMode() { markerMode = !markerMode; const btn = document.getElementById('tb-marker'); if (markerMode) { deactivateAllModes(); markerMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; } else { btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; } updateMapModeClass(); } function addMarker(lngLat) { const markers = loadMarkers(); if (markers.length >= 50) { alert('Достигнут лимит 50 меток'); return; } const grid = document.getElementById('marker-type-grid'); // Show marker dialog openMarkerDialog(lngLat); } function openMarkerDialog(lngLat) { const dialog = document.getElementById('marker-dialog'); const grid = document.getElementById('marker-type-grid'); grid.innerHTML = MARKER_ICONS.map((ic, i) => `` ).join(''); dialog.classList.add('open'); } function closeMarkerDialog() { document.getElementById('marker-dialog').classList.remove('open'); } function selectMarkerType(idx, lat, lng) { closeMarkerDialog(); const markers = loadMarkers(); const icon = MARKER_ICONS[idx] || MARKER_ICONS[0]; const autoName = `Метка ${markers.length + 1}`; const marker = { id: Date.now(), name: autoName, icon, lat, lon: lng }; markers.push(marker); saveMarkers(markers); drawNamedMarker(marker); } function drawNamedMarker(markerData) { const map = window._map; if (!map) return; const el = document.createElement('div'); el.className = 'named-marker-el'; el.textContent = markerData.icon; el.title = markerData.name; const popup = new maplibregl.Popup({ offset: 25, closeButton: true }) .setHTML(`
`); const mlMarker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) .setLngLat([markerData.lon, markerData.lat]) .setPopup(popup) .addTo(map); namedMarkerObjects[markerData.id] = mlMarker; } function renderMarkers() { // Clear existing marker objects to prevent duplicates Object.keys(namedMarkerObjects).forEach(id => { const obj = namedMarkerObjects[id]; if (obj) { const popup = obj.getPopup(); if (popup) popup.remove(); obj.remove(); } }); namedMarkerObjects = {}; // Re-draw from localStorage const markers = loadMarkers(); markers.forEach(m => drawNamedMarker(m)); } function removeMarker(id) { if (namedMarkerObjects[id]) { const popup = namedMarkerObjects[id].getPopup(); if (popup) popup.remove(); namedMarkerObjects[id].remove(); delete namedMarkerObjects[id]; } const markers = loadMarkers().filter(m => m.id !== id); saveMarkers(markers); } function useMarkerAsA(id) { const markers = loadMarkers(); const m = markers.find(x => x.id === id); if (!m) return; if (!routeMode) toggleRouteMode(); if (routeWaypoints.length === 0) routeWaypoints.push({ lon: m.lon, lat: m.lat }); else routeWaypoints[0] = { lon: m.lon, lat: m.lat }; rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); } function useMarkerAsB(id) { const markers = loadMarkers(); const m = markers.find(x => x.id === id); if (!m) return; if (!routeMode) toggleRouteMode(); if (routeWaypoints.length === 0) { routeWaypoints.push({ lon: m.lon, lat: m.lat }); routeWaypoints.push({ lon: m.lon, lat: m.lat }); } else if (routeWaypoints.length === 1) routeWaypoints.push({ lon: m.lon, lat: m.lat }); else routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat }; rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); } // ─── Map init ────────────────────────────────────────────────────── async function initMap() { const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; const tileBase = window.location.origin + basePath; const style = await fetch(basePath + '/style.json').then(r => r.json()); style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`]; const map = new maplibregl.Map({ container: 'map', style: style, center: [40.5, 55.5], zoom: 7, minZoom: 4, maxZoom: 18, hash: true, }); window._map = map; map.addControl(new maplibregl.NavigationControl(), 'top-left'); map.addControl(new maplibregl.FullscreenControl(), 'top-left'); // Custom scale + zoom indicator (one line, top-right) const scaleZoomBar = document.createElement('div'); scaleZoomBar.id = 'scale-zoom-bar'; scaleZoomBar.innerHTML = '
30 km
z7
'; document.getElementById('map').appendChild(scaleZoomBar); function updateScaleZoom() { const zoom = Math.round(map.getZoom()); const lat = map.getCenter().lat; const metersPerPixel = 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, map.getZoom()); const targetPx = 80; const rawMeters = metersPerPixel * targetPx; // ET-005 (риск R3): масштабная линейка учитывает выбранную единицу. // niceMeters всегда остаётся в метрах — по нему считается ширина в px. let distance, unit, niceMeters; if (window.Units && Units.getUnit() === 'mi') { const rawMiles = (rawMeters / 1000) * Units.KM_TO_MI; if (rawMiles >= 1) { distance = rawMiles >= 100 ? Math.round(rawMiles / 50) * 50 : rawMiles >= 10 ? Math.round(rawMiles / 5) * 5 : Math.round(rawMiles); } else { distance = Math.max(0.1, Math.round(rawMiles * 10) / 10); } unit = 'mi'; niceMeters = (distance / Units.KM_TO_MI) * 1000; } else if (rawMeters >= 1000) { const km = rawMeters / 1000; distance = km >= 100 ? Math.round(km / 50) * 50 : km >= 10 ? Math.round(km / 5) * 5 : km >= 1 ? Math.round(km) : Math.round(km * 10) / 10; unit = 'km'; niceMeters = distance * 1000; } else { distance = rawMeters >= 100 ? Math.round(rawMeters / 50) * 50 : rawMeters >= 10 ? Math.round(rawMeters / 5) * 5 : Math.round(rawMeters); unit = 'm'; niceMeters = distance; } const actualPx = Math.round(niceMeters / metersPerPixel); const clampedPx = Math.max(40, Math.min(150, actualPx)); const scaleEl = scaleZoomBar.querySelector('.szb-scale'); const labelEl = scaleZoomBar.querySelector('.szb-label'); const zoomEl = scaleZoomBar.querySelector('.szb-zoom'); scaleEl.style.width = clampedPx + 'px'; labelEl.textContent = distance + ' ' + unit; zoomEl.textContent = 'z' + zoom; } // ET-005: оркестратор onUnitChange() обновляет линейку при смене единицы. window._updateScaleZoom = updateScaleZoom; updateScaleZoom(); map.on('zoom', updateScaleZoom); map.on('move', updateScaleZoom); map.on('load', () => { checkDataAvailability(); initRouteClicks(map); initRulerClicks(map); renderMarkers(); // Apply theme on load applyTheme(); // Start auto-theme interval themeAutoInterval = setInterval(() => { if (themeMode === 'auto') applyAutoTheme(); }, 60000); }); map.on('style.load', () => { onMapStyleLoad(); }); map.on('error', (e) => { console.error('Map error:', e.error?.message || e); }); // Popup for trail features const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, maxWidth: '300px', }); // ET-005: расстояние во всплывающих подсказках — через units.js (ADR-0001). function formatLength(m) { if (!m) return '-'; return Units.formatDistance(m); } function poiTypeLabel(t) { const labels = { 'natural=peak': '⛰ Вершина', 'natural=water': '💧 Вода', 'tourism=viewpoint': '👁 Смотровая', 'historic=ruins': '🏚 Руины', 'natural=cave_entrance': '🕳 Пещера', 'ford=yes': '🌊 Брод', }; return labels[t] || t; } ['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => { map.on('click', layerId, (e) => { if (routeMode || rulerMode || markerMode) return; const props = e.features[0].properties; const html = ` ${props.mtb_scale ? `` : ''} `; popup.setLngLat(e.lngLat).setHTML(html).addTo(map); }); map.on('mouseenter', layerId, () => { if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layerId, () => { if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; }); }); map.on('click', 'poi-circles', (e) => { if (routeMode || rulerMode || markerMode) return; const props = e.features[0].properties; const html = ` `; popup.setLngLat(e.lngLat).setHTML(html).addTo(map); }); map.on('mouseenter', 'poi-circles', () => { if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'poi-circles', () => { if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; }); map.on('click', (e) => { if (routeMode || rulerMode || markerMode) return; const features = map.queryRenderedFeatures(e.point, { layers: ['trails-track', 'trails-path-bridleway', 'trails-asphalt', 'poi-circles'], }); if (!features.length) popup.remove(); }); } async function checkDataAvailability() { try { const basePath = getBasePath(); const resp = await fetch(basePath + '/api/health'); const data = await resp.json(); if (!data.db_exists) { document.getElementById('no-data-warning').classList.add('visible'); } } catch (e) { console.warn('Health check failed:', e); } } // ─── Клики на карте ──────────────────────────────────────────────── function initRouteClicks(map) { map.on('click', (e) => { const { lng, lat } = e.lngLat; if (reconMode) { doRecon(lng, lat); return; } if (linkMode) { addLinkPoint(lng, lat); return; } if (scenicMode) { scenicStart = { lon: lng, lat: lat }; document.getElementById('scenic-status').textContent = `📍 Старт: ${lat.toFixed(4)}, ${lng.toFixed(4)}`; if (scenicStartMarker) scenicStartMarker.remove(); const el = document.createElement('div'); el.className = ''; el.style.cssText = 'width:16px;height:16px;background:var(--accent);border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; scenicStartMarker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map); document.getElementById('btn-build-scenic').style.display = ''; return; } if (markerMode) { addMarker(e.lngLat); toggleMarkerMode(); return; } if (!routeMode) return; if (addingWaypoint) { addingWaypoint = false; map.getCanvas().style.cursor = 'crosshair'; if (routeWaypoints.length >= 2) { routeWaypoints.splice(routeWaypoints.length - 1, 0, { lon: lng, lat: lat }); } else { routeWaypoints.push({ lon: lng, lat: lat }); } hideMiniOnboard(); rebuildWaypointMarkers(); renderWaypointsList(); if (routeWaypoints.length >= 2) debounceBuildRoute(); updateMiniRouteCard(); return; } if (routeWaypoints.length === 0) { routeWaypoints.push({ lon: lng, lat: lat }); rebuildWaypointMarkers(); renderWaypointsList(); document.getElementById('route-status').textContent = 'Тапни точку финиша'; showRouteOnboardingMini(); // switch to finish prompt } else if (routeWaypoints.length === 1) { routeWaypoints.push({ lon: lng, lat: lat }); rebuildWaypointMarkers(); renderWaypointsList(); hideMiniOnboard(); buildRoute(); } }); } // ─── Поиск (Nominatim) ───────────────────────────────────────────── let searchTimeout = null; // ─── Waypoint inline search ──────────────────────────────────────── let wpSearchTimeout = null; function openWaypointSearch(idx) { // Close all other open panels document.querySelectorAll('.wl-search-panel').forEach(p => { if (p.id !== `wl-search-panel-${idx}`) p.style.display = 'none'; }); const panel = document.getElementById(`wl-search-panel-${idx}`); if (!panel) return; const isOpen = panel.style.display !== 'none'; panel.style.display = isOpen ? 'none' : 'block'; if (!isOpen) { const input = document.getElementById(`wl-search-input-${idx}`); if (input) { input.value = ''; input.focus(); input.addEventListener('input', () => { clearTimeout(wpSearchTimeout); const q = input.value.trim(); if (q.length < 2) { document.getElementById(`wl-search-results-${idx}`).innerHTML = ''; return; } wpSearchTimeout = setTimeout(() => doWaypointSearch(idx, q), 400); }); } } } async function doWaypointSearch(idx, query) { const resultsEl = document.getElementById(`wl-search-results-${idx}`); if (!resultsEl) return; resultsEl.innerHTML = '
Поиск...
'; try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&countrycodes=ru&accept-language=ru`; const resp = await fetch(url); const data = await resp.json(); if (!data.length) { resultsEl.innerHTML = '
Ничего не найдено
'; return; } resultsEl.innerHTML = data.map(item => { const parts = (item.display_name || '').split(', '); const name = parts[0]; const sub = parts.slice(1, 3).join(', '); return `
${name}
${sub ? `
${sub}
` : ''}
`; }).join(''); } catch(e) { resultsEl.innerHTML = '
Ошибка поиска
'; } } function selectWaypointSearchResult(idx, lat, lon, name) { routeWaypoints[idx] = { lat: parseFloat(lat), lon: parseFloat(lon) }; const panel = document.getElementById(`wl-search-panel-${idx}`); if (panel) panel.style.display = 'none'; rebuildWaypointMarkers(); renderWaypointsList(); window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 600 }); if (routeWaypoints.length >= 2) debounceBuildRoute(); updateMiniRouteCard(); } function initSearch() { const input = document.getElementById('search-input'); const results = document.getElementById('search-results'); input.addEventListener('input', () => { clearTimeout(searchTimeout); const q = input.value.trim(); if (q.length < 2) { results.style.display = 'none'; return; } searchTimeout = setTimeout(() => doSearch(q), 400); }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { results.style.display = 'none'; input.blur(); } }); document.addEventListener('click', (e) => { if (!e.target.closest('#search-bar') && !e.target.closest('#search-results')) { results.style.display = 'none'; } }); } async function doSearch(query) { const results = document.getElementById('search-results'); results.innerHTML = '
Поиск...
'; results.style.display = 'block'; try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`; const resp = await fetch(url, { headers: { 'Accept-Language': 'ru' } }); const data = await resp.json(); if (!data.length) { results.innerHTML = '
Ничего не найдено
'; return; } results.innerHTML = data.map((item) => { const name = item.display_name.split(',')[0]; const detail = item.display_name.split(',').slice(1, 3).join(',').trim(); return `
${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() { const btn = document.getElementById('tb-ruler'); if (rulerMode) { // Режим активен → выйти из режима, скрыть линейку (точки сохраняются в rulerPoints) rulerMode = false; btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; // Убрать маркеры с карты + удалить DOM-элементы rulerMarkers.forEach(m => m.remove()); rulerMarkers = []; const map = window._map; try { if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); } catch(e) {} try { if (map.getSource('ruler')) map.removeSource('ruler'); } catch(e) {} document.getElementById('ruler-info').classList.remove('visible'); updateMapModeClass(); } else if (rulerPoints.length > 0) { // Линейка скрыта, точки есть → восстановить и войти в режим рисования deactivateAllModes(); rulerMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; const pts = [...rulerPoints]; rulerPoints = []; rulerTotal = 0; rulerMarkers.forEach(m => m.remove()); // удалить старые маркеры с карты rulerMarkers = []; pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); document.getElementById('ruler-info').classList.add('visible'); updateMapModeClass(); } else { // Нет линейки → войти в режим рисования deactivateAllModes(); rulerMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; showRulerToast(); updateMapModeClass(); } } function deleteRuler() { rulerMode = false; const btn = document.getElementById('tb-ruler'); if (btn) btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; clearRuler(); document.getElementById('ruler-info').classList.remove('visible'); updateMapModeClass(); } // Fix 4: Toast hint helper function showRulerToast() { const toast = document.getElementById('ruler-toast'); if (!toast) return; toast.classList.add('visible'); setTimeout(() => toast.classList.remove('visible'), 3000); } // Exit ruler mode without clearing points/markers ("Завершить") function exitRulerMode() { if (!rulerMode) return; rulerMode = false; const btn = document.getElementById('tb-ruler'); btn.classList.remove('active'); window._map.getCanvas().style.cursor = ''; document.getElementById('ruler-info').classList.remove('visible'); updateMapModeClass(); } function clearRuler() { rulerPoints = []; rulerTotal = 0; rulerMarkers.forEach(m => m.remove()); rulerMarkers = []; const map = window._map; try { if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); } catch(e) {} try { if (map.getSource('ruler')) map.removeSource('ruler'); } catch(e) {} } function haversineKm(a, b) { const R = 6371; const dLat = (b[1] - a[1]) * Math.PI / 180; const dLon = (b[0] - a[0]) * Math.PI / 180; const s = Math.sin(dLat/2)**2 + Math.cos(a[1]*Math.PI/180) * Math.cos(b[1]*Math.PI/180) * Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); } function updateRulerLine() { const map = window._map; const geojson = { type: 'Feature', geometry: { type: 'LineString', coordinates: rulerPoints } }; if (map.getSource('ruler')) { map.getSource('ruler').setData(geojson); } else { map.addSource('ruler', { type: 'geojson', data: geojson }); map.addLayer({ id: 'ruler-line', type: 'line', source: 'ruler', paint: { 'line-color': '#0088ff', 'line-width': 2, 'line-dasharray': [4, 2], 'line-opacity': 0.9 } }); } // Update ruler info display // ET-005: rulerTotal хранится в км — переводим в метры для units.js. const dist = Units.formatDistance(rulerTotal * 1000); document.getElementById('ruler-dist').textContent = dist; } function removeRulerPoint(idx) { const map = window._map; // Remove marker from map rulerMarkers[idx].remove(); rulerMarkers.splice(idx, 1); rulerPoints.splice(idx, 1); // Recalculate total and update all labels updateRulerLabels(); updateRulerLine(); } function updateRulerLabels() { // Recalculate rulerTotal from scratch and update label elements on each marker rulerTotal = 0; for (let i = 0; i < rulerMarkers.length; i++) { const markerEl = rulerMarkers[i].getElement(); const dot = markerEl.querySelector('.ruler-dot'); const label = markerEl.querySelector('.ruler-label'); const btn = markerEl.querySelector('.ruler-remove-btn'); const labelText = label ? label.querySelector('span') : null; // Fix 5: Update dot color for first point if (dot) { const dotColor = i === 0 ? '#2EA043' : '#0088ff'; dot.style.background = dotColor; } if (i === 0) { if (labelText) labelText.textContent = 'Старт'; } else { const segDist = haversineKm(rulerPoints[i - 1], rulerPoints[i]); rulerTotal += segDist; if (labelText) { // ET-005: segDist в км — переводим в метры для units.js. labelText.textContent = Units.formatDistance(segDist * 1000); } } // Update remove button index if (btn) { btn.onclick = (e) => { e.stopPropagation(); removeRulerPoint(i); }; } } // Update total display // ET-005: rulerTotal хранится в км — переводим в метры для units.js. const dist = Units.formatDistance(rulerTotal * 1000); document.getElementById('ruler-dist').textContent = dist; } function addRulerPoint(lngLat) { const map = window._map; const pt = [lngLat.lng, lngLat.lat]; const idx = rulerPoints.length; rulerPoints.push(pt); let segDist = 0; if (idx > 0) { segDist = haversineKm(rulerPoints[idx - 1], pt); rulerTotal += segDist; } // Bug 3: hide toast on ANY tap, not just the first const toast = document.getElementById('ruler-toast'); if (toast) toast.classList.remove('visible'); // Bug 4: show ruler-info panel after first point is added if (idx === 0) { document.getElementById('ruler-info').classList.add('visible'); } // Wrapper element for dot + label row // Bug 6: wrapper is flex-column; label is absolute below dot so anchor:'center' hits the dot const wrapper = document.createElement('div'); wrapper.style.cssText = 'position:relative;display:flex;flex-direction:column;align-items:center;'; // Dot - first point green const dot = document.createElement('div'); dot.className = 'ruler-dot'; const dotColor = idx === 0 ? '#2EA043' : '#0088ff'; dot.style.cssText = `width:10px;height:10px;background:${dotColor};border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3);display:block;flex-shrink:0;`; // Label row: [distance text][× button] — absolutely positioned below dot const label = document.createElement('div'); label.className = 'ruler-label'; label.style.cssText = 'position:absolute;top:100%;margin-top:3px;display:flex;align-items:center;gap:4px;background:rgba(20,20,20,0.75);color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;white-space:nowrap;'; const labelText = document.createElement('span'); if (idx === 0) { labelText.textContent = 'Старт'; } else { // ET-005: segDist в км — переводим в метры для units.js. labelText.textContent = Units.formatDistance(segDist * 1000); } // Bug 5: use button element for better tap target and semantics const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ruler-remove-btn'; btn.textContent = '×'; btn.style.cssText = 'background:none;border:none;cursor:pointer;font-size:16px;line-height:1;opacity:0.85;padding:4px 8px;min-width:32px;min-height:32px;display:flex;align-items:center;justify-content:center;margin:-2px -6px -2px 0;color:#fff;'; btn.onclick = (e) => { e.stopPropagation(); removeRulerPoint(idx); }; label.appendChild(labelText); label.appendChild(btn); wrapper.appendChild(dot); wrapper.appendChild(label); // Bug 2: tap on marker wrapper resumes ruler mode wrapper.addEventListener('click', (e) => { e.stopPropagation(); if (!rulerMode && rulerPoints.length > 0) { rulerMode = true; document.getElementById('tb-ruler').classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; updateMapModeClass(); } document.getElementById('ruler-info').classList.add('visible'); }); const dotMarker = new maplibregl.Marker({ element: wrapper, anchor: 'center' }) .setLngLat([lngLat.lng, lngLat.lat]) .addTo(map); rulerMarkers.push(dotMarker); updateRulerLine(); } function initRulerClicks(map) { map.on('click', (e) => { if (!rulerMode) return; addRulerPoint(e.lngLat); }); map.on('dblclick', (e) => { if (!rulerMode) return; e.preventDefault(); exitRulerMode(); }); // Fix 2 & 6: tap on ruler line shows panel AND resumes ruler mode map.on('click', 'ruler-line', (e) => { e.originalEvent.stopPropagation(); if (rulerPoints.length > 0) { // Fix 6: Resume ruler mode if (!rulerMode) { rulerMode = true; const btn = document.getElementById('tb-ruler'); btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; updateMapModeClass(); } document.getElementById('ruler-info').classList.add('visible'); } }); } // ─── Фаза 4: Разведка ───────────────────────────────────────────── let reconMode = false; let reconCenter = null; let reconRadius = 20; function toggleReconMode() { const btn = document.getElementById('tb-recon'); if (reconMode) { // Exit recon mode reconMode = false; btn.classList.remove('active'); closeSheet('sheet-recon'); window._map.getCanvas().style.cursor = ''; clearRecon(); // recon data is transient - safe to clear } else { deactivateAllModes(); reconMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; openSheet('sheet-recon'); } updateMapModeClass(); } function makeCircleGeoJSON(lon, lat, radiusKm) { const coords = []; for (let i = 0; i <= 64; i++) { const a = (2 * Math.PI * i) / 64; const dlat = (radiusKm / 111) * Math.cos(a); const dlon = (radiusKm / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(a); coords.push([lon + dlon, lat + dlat]); } return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: {} }; } async function doRecon(lon, lat) { reconCenter = [lon, lat]; const map = window._map; const circle = makeCircleGeoJSON(lon, lat, reconRadius); if (map.getSource('recon-circle')) { map.getSource('recon-circle').setData(circle); } else { map.addSource('recon-circle', { type: 'geojson', data: circle }); map.addLayer({ id: 'recon-circle-fill', type: 'fill', source: 'recon-circle', paint: { 'fill-color': '#ff6600', 'fill-opacity': 0.08 } }); map.addLayer({ id: 'recon-circle-stroke', type: 'line', source: 'recon-circle', paint: { 'line-color': '#ff6600', 'line-width': 2, 'line-opacity': 0.5 } }); } const basePath = getBasePath(); const resultsDiv = document.getElementById('recon-results'); resultsDiv.style.display = 'block'; try { const resp = await fetch(`${basePath}/api/recon`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lon, lat, radius_km: reconRadius }) }); const data = await resp.json(); const t = data.trails || {}; const p = data.poi || {}; document.getElementById('r-total-km').textContent = t.total_km || 0; document.getElementById('r-lev12-km').textContent = t.lev12_km || 0; document.getElementById('r-lev345-km').textContent = t.lev345_km || 0; document.getElementById('r-path-km').textContent = t.path_km || 0; const poiList = document.getElementById('r-poi-list'); const poiTypes = [ { key: 'natural=water', icon: '💧', label: 'Озёра' }, { key: 'tourism=viewpoint', icon: '👁', label: 'Смотровая' }, { key: 'ford=yes', icon: '🌊', label: 'Броды' }, { key: 'historic=ruins', icon: '🏚', label: 'Руины' }, ]; poiList.innerHTML = poiTypes.map(pt => `
${pt.icon} ${pt.label} ${p[pt.key] || 0}
` ).join(''); } catch(e) { document.getElementById('r-total-km').textContent = '-'; } } function setReconRadius(km) { reconRadius = km; document.querySelectorAll('.seg-btn[data-km]').forEach(b => { b.classList.toggle('active', +b.dataset.km === km); }); if (reconCenter) doRecon(reconCenter[0], reconCenter[1]); } function clearRecon() { const map = window._map; try { if (map.getLayer('recon-circle-fill')) map.removeLayer('recon-circle-fill'); } catch(e) {} try { if (map.getLayer('recon-circle-stroke')) map.removeLayer('recon-circle-stroke'); } catch(e) {} try { if (map.getSource('recon-circle')) map.removeSource('recon-circle'); } catch(e) {} closeSheet('sheet-recon'); reconCenter = null; } // ─── Фаза 4: Связка ──────────────────────────────────────────────── let linkMode = false; let linkPoints = []; let linkMarkers = []; // ET-005: последние построенные связки кэшируются, чтобы оркестратор // onUnitChange() мог перерисовать карточки без повторного запроса к API. let linkRoutes = []; function toggleLinkMode() { const btn = document.getElementById('tb-link'); if (linkMode) { // Exit link mode linkMode = false; btn.classList.remove('active'); closeSheet('sheet-link'); window._map.getCanvas().style.cursor = ''; clearLink(); } else { deactivateAllModes(); linkMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; openSheet('sheet-link'); document.getElementById('link-status').textContent = '1️⃣ Тапни конец первого трека'; document.getElementById('link-cards').innerHTML = ''; linkPoints = []; linkMarkers.forEach(m => m.remove()); linkMarkers = []; } updateMapModeClass(); } function addLinkPoint(lng, lat) { const map = window._map; linkPoints.push({ lon: lng, lat: lat }); const idx = linkPoints.length; const el = document.createElement('div'); el.className = ''; el.style.cssText = 'width:16px;height:16px;background:var(--accent);border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;'; el.textContent = idx; const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }).setLngLat([lng, lat]).addTo(map); linkMarkers.push(marker); if (idx === 1) { document.getElementById('link-pt-1').classList.remove('empty'); document.getElementById('link-pt-1').querySelector('.link-pt-label').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; document.getElementById('link-status').textContent = '2️⃣ Тапни начало второго трека'; } else if (idx >= 2) { document.getElementById('link-pt-2').classList.remove('empty'); document.getElementById('link-pt-2').querySelector('.link-pt-label').textContent = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; showSkeleton('link-cards', 2); buildLinkRoute(); } } async function buildLinkRoute() { const map = window._map; document.getElementById('link-status').textContent = '⏳ Ищу связку...'; const basePath = getBasePath(); try { const resp = await fetch(`${basePath}/api/route`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ waypoints: linkPoints, alternatives: 3 }) }); if (!resp.ok) throw new Error('Не найдена'); const data = await resp.json(); if (data.routes && data.routes.length > 0) { renderLinkCards(data.routes); document.getElementById('link-status').textContent = '✅ Связка найдена'; } else { document.getElementById('link-status').textContent = '❌ Грунтовая связка не найдена'; } } catch(e) { document.getElementById('link-status').textContent = '❌ ' + e.message; } } function renderLinkCards(routes) { const map = window._map; linkRoutes = routes; // ET-005: кэш для перерисовки при смене единицы const colors = ['#0066ff', '#00aa44', '#9933cc']; const cardsEl = document.getElementById('link-cards'); cardsEl.innerHTML = ''; routes.forEach((r, i) => { const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; const sid = `link-src-${i}`; const lid = `link-line-${i}`; try { if (map.getSource(sid)) map.removeSource(sid); } catch(e) {} try { if (map.getLayer(lid)) map.removeLayer(lid); } catch(e) {} map.addSource(sid, { type: 'geojson', data: geojson }); map.addLayer({ id: lid, type: 'line', source: sid, paint: { 'line-color': colors[i % colors.length], 'line-width': i === 0 ? 5 : 3, 'line-opacity': i === 0 ? 0.9 : 0.5, }, layout: { 'line-cap': 'round', 'line-join': 'round' } }); const 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} ${Units.formatDistance(r.distance_m, { precision: 0 })} ${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)); for (let i = 0; i < 3; i++) { const lid = `link-line-${i}`; try { if (map.getLayer(lid)) { map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); } } catch(e) {} } } function clearLink() { linkPoints = []; linkMarkers.forEach(m => m.remove()); linkMarkers = []; const map = window._map; for (let i = 0; i < 5; i++) { try { if (map.getLayer(`link-line-${i}`)) map.removeLayer(`link-line-${i}`); } catch(e) {} try { if (map.getSource(`link-src-${i}`)) map.removeSource(`link-src-${i}`); } catch(e) {} } closeSheet('sheet-link'); document.getElementById('link-cards').innerHTML = ''; // Reset link point UI const pt1 = document.getElementById('link-pt-1'); const pt2 = document.getElementById('link-pt-2'); if (pt1) { pt1.classList.add('empty'); pt1.querySelector('.link-pt-label').textContent = 'Конец первого трека'; } if (pt2) { pt2.classList.add('empty'); pt2.querySelector('.link-pt-label').textContent = 'Начало второго трека'; } } // ─── Фаза 4: Красивый маршрут ────────────────────────────────────── let scenicMode = false; let scenicStart = null; let scenicStartMarker = null; let scenicTargetKm = 100; let scenicRoutes = []; let activeScenicIdx = 0; function toggleScenicMode() { const btn = document.getElementById('tb-scenic'); if (scenicMode) { // Exit scenic mode scenicMode = false; btn.classList.remove('active'); closeSheet('sheet-scenic'); window._map.getCanvas().style.cursor = ''; clearScenic(); } else { deactivateAllModes(); scenicMode = true; btn.classList.add('active'); window._map.getCanvas().style.cursor = 'crosshair'; openSheet('sheet-scenic'); document.getElementById('scenic-status').textContent = 'Тапни точку старта на карте'; document.getElementById('btn-build-scenic').style.display = 'none'; } updateMapModeClass(); } function setScenicKm(km) { scenicTargetKm = km; document.querySelectorAll('#sheet-scenic .seg-btn[data-km]').forEach(b => { b.classList.toggle('active', +b.dataset.km === km); }); const inp = document.getElementById('scenic-custom-km'); if (inp) inp.value = km; } async function buildScenicRoute() { if (!scenicStart) return; const map = window._map; document.getElementById('scenic-status').textContent = '⏳ Строю красивый маршрут...'; showSkeleton('scenic-cards', 2); const btn = document.getElementById('btn-build-scenic'); btn.disabled = true; const basePath = getBasePath(); try { const resp = await fetch(`${basePath}/api/scenic`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lon: scenicStart.lon, lat: scenicStart.lat, target_km: scenicTargetKm }) }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || 'Ошибка'); } const data = await resp.json(); scenicRoutes = data.routes || []; if (scenicRoutes.length === 0) throw new Error('Маршрут не найден'); drawScenicRoutes(scenicRoutes, 0); document.getElementById('scenic-status').textContent = `✅ ${scenicRoutes.length} маршрут(ов)`; } catch(e) { document.getElementById('scenic-status').textContent = '❌ ' + e.message; document.getElementById('scenic-cards').innerHTML = ''; } btn.disabled = false; } function drawScenicRoutes(routes, activeIdx) { const map = window._map; scenicRoutes = routes; activeScenicIdx = activeIdx; // Clear old for (let i = 0; i < 10; i++) { try { if (map.getLayer(`scenic-line-${i}`)) map.removeLayer(`scenic-line-${i}`); } catch(e) {} try { if (map.getSource(`scenic-src-${i}`)) map.removeSource(`scenic-src-${i}`); } catch(e) {} } const colors = ['#0066ff', '#00aa44', '#9933cc']; routes.forEach((r, i) => { const geojson = { type: 'Feature', geometry: r.geometry, properties: {} }; const sid = `scenic-src-${i}`; const lid = `scenic-line-${i}`; map.addSource(sid, { type: 'geojson', data: geojson }); map.addLayer({ id: lid, type: 'line', source: sid, paint: { 'line-color': colors[i % colors.length], 'line-width': i === activeIdx ? 5 : 3, 'line-opacity': i === activeIdx ? 0.9 : 0.5, }, layout: { 'line-cap': 'round', 'line-join': 'round' } }); }); const cardsEl = document.getElementById('scenic-cards'); if (cardsEl) { cardsEl.innerHTML = routes.map((r, i) => { const col = colors[i % colors.length]; const 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)} ${Units.formatDistance(r.distance_m, { precision: 0 })} ${time}
${dirt}% грунт · score=${r.scenic_score||0}
${pois ? '
'+pois+'
' : ''}
`; }).join(''); } } function selectScenicRoute(idx) { activeScenicIdx = idx; const map = window._map; scenicRoutes.forEach((_, i) => { const lid = `scenic-line-${i}`; try { if (map.getLayer(lid)) { map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3); map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5); } } catch(e) {} }); document.querySelectorAll('#scenic-cards .route-card').forEach((c, i) => { c.classList.toggle('active', i === idx); }); } function clearScenic() { const map = window._map; for (let i = 0; i < 10; i++) { try { if (map.getLayer(`scenic-line-${i}`)) map.removeLayer(`scenic-line-${i}`); } catch(e) {} try { if (map.getSource(`scenic-src-${i}`)) map.removeSource(`scenic-src-${i}`); } catch(e) {} } if (scenicStartMarker) { scenicStartMarker.remove(); scenicStartMarker = null; } scenicStart = null; scenicRoutes = []; closeSheet('sheet-scenic'); const cardsEl = document.getElementById('scenic-cards'); if (cardsEl) cardsEl.innerHTML = ''; } // ─── Init on page load ───────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initMap(); initSheetSwipe(); // Apply saved theme immediately (before map loads) applyTheme(); // ET-005: восстановить выбор единиц измерения (AC-3) и подключить // единый оркестратор пересчёта расстояний (ADR-0001 п.6). syncUnitToggleUI(); document.addEventListener('unitchange', onUnitChange); }); // ─── Mini Route Bar ────────────────────────────────────────────────── function miniAddWaypoint() { // Enter waypoint-adding mode without opening full sheet if (!routeMode) { routeMode = true; document.getElementById('tb-route').classList.add('active'); updateMapModeClass(); } addWaypointMode(); // Show hint on mini-bar const statsEl = document.getElementById('mini-stats'); if (statsEl) statsEl.textContent = 'Тапни на карте для добавления точки'; } window.cancelAddWaypoint = cancelAddWaypoint; function cancelAddWaypoint() { addingWaypoint = false; window._map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; hideMiniOnboard(); // Просто показать мини-бар без открытия листа if (routeResults.length > 0) { // Показать мини-бар с результатом маршрута (без открытия листа) updateMiniRouteCard(); document.getElementById('sheet-route-mini').classList.add('visible'); const ctrl = document.getElementById('map-controls-r'); if (ctrl) ctrl.style.bottom = '148px'; initMiniRouteInteraction(); } else if (routeWaypoints.length > 0) { showRouteOnboardingMini(); } else { hideMiniRouteSheet(); } } function showRouteOnboardingMini() { const cancelBtn = document.getElementById('mini-onboard-cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; if (routeWaypoints.length >= 2) { hideMiniOnboard(); showMiniRouteSheet(); return; } const isStart = routeWaypoints.length === 0; const label = isStart ? 'S' : 'F'; const color = isStart ? '#2EA043' : '#FF3B1F'; const hint = isStart ? 'Тапни на карте — старт' : 'Тапни на карте — финиш'; // Show onboarding div, hide normal mini-bar content document.getElementById('mini-onboard').style.display = 'flex'; document.getElementById('mini-dot').style.display = 'none'; document.getElementById('mini-label').style.display = 'none'; document.getElementById('mini-stats').style.display = 'none'; document.getElementById('mini-wheel').style.display = 'none'; const arrows = document.querySelector('.mini-route-arrows'); if (arrows) arrows.style.display = 'none'; const addBtn = document.getElementById('mini-add-btn'); if (addBtn) addBtn.style.display = 'none'; // Set pin and hint document.getElementById('mini-onboard-pin').innerHTML = waypointPinSvg(label, color); document.getElementById('mini-onboard-hint').textContent = hint; // Show mini-bar and raise map controls document.getElementById('sheet-route-mini').classList.add('visible'); const ctrl = document.getElementById('map-controls-r'); if (ctrl) ctrl.style.bottom = '148px'; // Search button handler const searchBtn = document.getElementById('mini-onboard-search-btn'); searchBtn.onclick = () => toggleMiniOnboardSearch(isStart ? 'start' : 'finish'); } function hideMiniOnboard() { document.getElementById('mini-onboard').style.display = 'none'; document.getElementById('mini-onboard-search-panel').style.display = 'none'; const cancelBtn = document.getElementById('mini-onboard-cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; // Restore normal mini-bar elements document.getElementById('mini-dot').style.display = ''; document.getElementById('mini-label').style.display = ''; document.getElementById('mini-stats').style.display = ''; document.getElementById('mini-wheel').style.display = ''; const arrows = document.querySelector('.mini-route-arrows'); if (arrows) arrows.style.display = ''; const addBtn = document.getElementById('mini-add-btn'); if (addBtn) addBtn.style.display = ''; } function toggleMiniOnboardSearch(type) { const panel = document.getElementById('mini-onboard-search-panel'); const isVisible = panel.style.display !== 'none'; if (isVisible) { panel.style.display = 'none'; return; } panel.style.display = 'block'; const input = document.getElementById('mini-onboard-search-input'); input.value = ''; document.getElementById('mini-onboard-search-results').innerHTML = ''; setTimeout(() => input.focus(), 50); let timeout = null; input.oninput = () => { clearTimeout(timeout); const q = input.value.trim(); const resultsEl = document.getElementById('mini-onboard-search-results'); if (q.length < 2) { resultsEl.innerHTML = ''; return; } timeout = setTimeout(() => _doMiniOnboardSearch(type, q, resultsEl), 400); }; } async function _doMiniOnboardSearch(type, query, resultsEl) { resultsEl.innerHTML = '
Поиск...
'; try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&accept-language=ru`; const resp = await fetch(url); const data = await resp.json(); if (!data.length) { resultsEl.innerHTML = '
Ничего не найдено
'; return; } resultsEl.innerHTML = data.map(item => { const parts = (item.display_name || '').split(', '); const name = parts[0]; const sub = parts.slice(1, 3).join(', '); return `
${name}
${sub ? `
${sub}
` : ''}
`; }).join(''); } catch(e) { resultsEl.innerHTML = '
Ошибка
'; } } function _selectMiniOnboardResult(type, lat, lon, name) { const wp = { lat: parseFloat(lat), lon: parseFloat(lon) }; if (type === 'start') { routeWaypoints.unshift(wp); } else if (type === 'finish') { routeWaypoints.push(wp); } else if (type === 'waypoint') { // Insert before last waypoint (same as addingWaypoint tap logic) if (routeWaypoints.length >= 2) { routeWaypoints.splice(routeWaypoints.length - 1, 0, wp); } else { routeWaypoints.push(wp); } addingWaypoint = false; } document.getElementById('mini-onboard-search-panel').style.display = 'none'; rebuildWaypointMarkers(); window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 12, duration: 600 }); if (routeWaypoints.length >= 2) { hideMiniOnboard(); debounceBuildRoute(); } else { showRouteOnboardingMini(); // switch to finish } updateMiniRouteCard(); } function showMiniRouteSheet() { if (!routeResults || routeResults.length === 0) return; updateMiniRouteCard(); document.getElementById('sheet-route-mini').classList.add('visible'); // Поднять кнопки карты над мини-баром (64px высота + 72px bottom + 8px отступ) const ctrl = document.getElementById('map-controls-r'); if (ctrl) ctrl.style.bottom = '148px'; initMiniRouteInteraction(); } function hideMiniRouteSheet() { const el = document.getElementById('sheet-route-mini'); if (el) el.classList.remove('visible'); // Вернуть кнопки на место const ctrl = document.getElementById('map-controls-r'); if (ctrl) ctrl.style.bottom = ''; } function updateMiniRouteCard() { const r = routeResults[activeRouteIdx]; if (!r) return; const dirt = r.stats?.dirt_total_pct ?? '-'; document.getElementById('mini-dot').style.background = ROUTE_COLORS[activeRouteIdx % ROUTE_COLORS.length]; document.getElementById('mini-label').textContent = `Вариант ${activeRouteIdx + 1} из ${routeResults.length}`; document.getElementById('mini-stats').textContent = `${Units.formatDistance(r.distance_m)} · ${dirt}% грунт`; document.getElementById('mini-prev').style.opacity = activeRouteIdx > 0 ? '1' : '0.3'; document.getElementById('mini-next').style.opacity = activeRouteIdx < routeResults.length - 1 ? '1' : '0.3'; } function selectMiniRoute(idx) { if (idx < 0 || idx >= routeResults.length) return; activeRouteIdx = idx; const map = window._map; for (let i = 0; i < routeResults.length; i++) { const op = i === idx ? 1 : 0.35; try { if (map.getLayer('route-line-' + i)) map.setPaintProperty('route-line-' + i, 'line-opacity', op); if (map.getLayer('route-line-' + i + '-outline')) map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', i === idx ? 0.6 : 0); } catch(e) {} } updateMiniRouteCard(); renderWaypointsList(); } // ─── Route Loading Indicators ──────────────────────────────────── function showRouteLoading() { const el = document.getElementById('route-cards'); if (el) el.innerHTML = `
Строю маршрут...
`; } function showMiniRouteLoading() { const wheel = document.getElementById('mini-wheel'); const statsEl = document.getElementById('mini-stats'); if (wheel) wheel.classList.add('spinning'); if (statsEl) statsEl.textContent = 'Строю маршрут...'; document.getElementById('sheet-route-mini').classList.add('visible'); } function hideMiniRouteLoading() { const wheel = document.getElementById('mini-wheel'); if (wheel) wheel.classList.remove('spinning'); } function initMiniRouteInteraction() { const mini = document.getElementById('sheet-route-mini'); if (!mini) return; // Replace element to drop all old listeners const newMini = mini.cloneNode(true); mini.parentNode.replaceChild(newMini, mini); // Re-bind arrow buttons document.getElementById('mini-prev').onclick = (e) => { e.stopPropagation(); selectMiniRoute(activeRouteIdx - 1); }; document.getElementById('mini-next').onclick = (e) => { e.stopPropagation(); selectMiniRoute(activeRouteIdx + 1); }; const addBtn = document.getElementById('mini-add-btn'); if (addBtn) addBtn.onclick = (e) => { e.stopPropagation(); miniAddWaypoint(); }; let startX = 0, startY = 0; newMini.addEventListener('touchstart', e => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true }); newMini.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - startX; const dy = e.changedTouches[0].clientY - startY; if (Math.abs(dy) > Math.abs(dx)) { if (dy < -40) { hideMiniRouteSheet(); openSheet('sheet-route'); selectRoute(activeRouteIdx); } } else { if (dx < -40) selectMiniRoute(activeRouteIdx + 1); if (dx > 40) selectMiniRoute(activeRouteIdx - 1); } }); newMini.addEventListener('click', e => { if (e.target.classList.contains('mini-arrow')) return; hideMiniRouteSheet(); openSheet('sheet-route'); selectRoute(activeRouteIdx); }); } // ═══════════════════════════════════════════ // TERRAIN LAYERS (Phase 5.4) // ═══════════════════════════════════════════ const TERRAIN_BASE_URL = window.location.pathname.replace(/\/[^/]*$/, '') + '/terrain'; function toggleTerrainPopup() { const popup = document.getElementById('terrain-popup'); const btn = document.getElementById('terrain-toggle'); if (!popup || !btn) return; const isVisible = popup.style.display !== 'none'; popup.style.display = isVisible ? 'none' : 'block'; // Position popup to the left of the button if (!isVisible) { const rect = btn.getBoundingClientRect(); popup.style.right = (window.innerWidth - rect.left + 8) + 'px'; // Position: align bottom of popup with bottom of button, ensure fits in viewport const popupHeight = popup.offsetHeight; const desiredTop = rect.bottom - popupHeight; const minTop = 8; popup.style.top = Math.max(minTop, desiredTop) + 'px'; updateHillshadeAvailability(); setTimeout(() => { document.addEventListener('click', closeTerrainOnOutside); }, 10); } else { document.removeEventListener('click', closeTerrainOnOutside); } btn.classList.toggle('active', !isVisible); } function closeTerrainOnOutside(e) { const popup = document.getElementById('terrain-popup'); const btn = document.getElementById('terrain-toggle'); if (!popup.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { popup.style.display = 'none'; btn.classList.remove('active'); document.removeEventListener('click', closeTerrainOnOutside); } } function onTerrainCheckbox() { const map = window._map; if (!map) return; const hillshadeChecked = document.getElementById('terrain-hillshade-cb').checked; const triChecked = document.getElementById('terrain-tri-cb').checked; // Save state localStorage.setItem('terrain-hillshade', hillshadeChecked ? '1' : '0'); localStorage.setItem('terrain-tri', triChecked ? '1' : '0'); // Update button active state const btn = document.getElementById('terrain-toggle'); btn.classList.toggle('active', hillshadeChecked || triChecked); // Apply layers applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15); applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, 0.70, 5, 15); } function onTrailsCheckbox() { const map = window._map; if (!map) return; const trackChecked = document.getElementById('trails-track-cb').checked; const pathChecked = document.getElementById('trails-path-cb').checked; // Save state localStorage.setItem('trails-track', trackChecked ? '1' : '0'); localStorage.setItem('trails-path', pathChecked ? '1' : '0'); // Toggle layer visibility if (map.getLayer('trails-track')) { map.setLayoutProperty('trails-track', 'visibility', trackChecked ? 'visible' : 'none'); } if (map.getLayer('trails-path-bridleway')) { map.setLayoutProperty('trails-path-bridleway', 'visibility', pathChecked ? 'visible' : 'none'); } // ET-007 P1-6: синхронизируем halo-underlay-слои с состоянием // чекбоксов, чтобы на спутнике не оставалось «фантома» halo при // выключенной грунтовке/тропе. Безопасно к ранней инициализации: // _applyTrailHaloVisibility определена ниже в том же файле (ET-007 // base layer block). См. ADR-004 §9, TRZ §5.7. if (typeof _applyTrailHaloVisibility === 'function' && typeof getStoredBaseLayer === 'function') { _applyTrailHaloVisibility(map, getStoredBaseLayer()); } } function restoreTrailsState() { const trackState = localStorage.getItem('trails-track'); const pathState = localStorage.getItem('trails-path'); // Default: both checked (visible) const trackOn = trackState === null || trackState === '1'; const pathOn = pathState === null || pathState === '1'; const trackCb = document.getElementById('trails-track-cb'); const pathCb = document.getElementById('trails-path-cb'); if (trackCb) trackCb.checked = trackOn; if (pathCb) pathCb.checked = pathOn; const map = window._map; if (map) { if (map.getLayer('trails-track')) { map.setLayoutProperty('trails-track', 'visibility', trackOn ? 'visible' : 'none'); } if (map.getLayer('trails-path-bridleway')) { map.setLayoutProperty('trails-path-bridleway', 'visibility', pathOn ? 'visible' : 'none'); } // ET-007 P1-6: тот же контракт, что в onTrailsCheckbox (см. выше). if (typeof _applyTrailHaloVisibility === 'function' && typeof getStoredBaseLayer === 'function') { _applyTrailHaloVisibility(map, getStoredBaseLayer()); } } } // >>> ET-002 POI visibility block (do not remove markers — used by unit tests) >>> // Видимость POI (слои poi-circles, poi-labels) управляется чекбоксом // «POI» в попапе рельефа. Состояние хранится в localStorage под ключом // 'poi-visible' ('1'/'0'). Источник истины в рантайме — layerState.poi. // См. docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md /** * Применяет видимость группы слоёв POI и синхронизирует layerState.poi. * * Единый приватный хелпер: переиспользуется чекбоксом попапа * (onPoiCheckbox) и восстановлением состояния при загрузке/смене стиля * (restorePoiState). Не пишет в localStorage — персистентность остаётся * ответственностью обработчика чекбокса. * * @param {boolean} visible - true — показать POI, false — скрыть. */ function applyPoiVisibility(visible) { layerState.poi = visible; const map = window._map; if (!map) return; const visibility = visible ? 'visible' : 'none'; layerGroups.poi.forEach(id => { if (map.getLayer(id)) { map.setLayoutProperty(id, 'visibility', visibility); } }); } /** * Обработчик чекбокса «POI» в попапе рельефа (атрибут onchange). * * Сохраняет выбор в localStorage ('poi-visible': '1' видимы | '0' скрыты) * и применяет видимость слоёв POI через applyPoiVisibility(). */ function onPoiCheckbox() { const checked = document.getElementById('poi-visible-cb').checked; localStorage.setItem('poi-visible', checked ? '1' : '0'); applyPoiVisibility(checked); } /** * Восстанавливает видимость POI при загрузке страницы и после смены * стиля карты (переключение темы). * * По умолчанию (ключ 'poi-visible' отсутствует или равен '1') POI * видимы; '0' — скрыты. Синхронизирует чекбокс, layerState.poi и * фактическую видимость слоёв. */ function restorePoiState() { const stored = localStorage.getItem('poi-visible'); const poiOn = stored === null || stored === '1'; const cb = document.getElementById('poi-visible-cb'); if (cb) cb.checked = poiOn; applyPoiVisibility(poiOn); } // <<< ET-002 POI visibility block <<< // >>> ET-007 base layer toggle block (do not remove markers — used by unit tests) >>> // Переключатель базовой подложки карты «Схема» / «Спутник» в попапе слоёв. // Реализация: ленивое создание спутникового raster-source/layer при первом // включении «Спутника»; восстановление выбора из localStorage и // rebuildMapOverlays() после смены темы. POI / trails halo переключаются // через visibility у декларативных underlay-слоёв (`*-halo-satellite`) и // setPaintProperty у POI labels/circles. См. // docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md. /** * Параметры спутникового источника и слоя (ADR-004 §4.1, TRZ §4.1). * URL без API-ключа, HTTPS обязателен, атрибуция Esri. */ const SATELLITE_SOURCE_ID = 'satellite-raster'; const SATELLITE_LAYER_ID = 'satellite-base'; const SATELLITE_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; const SATELLITE_ATTRIBUTION = 'Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community'; /** * Halo-underlay-слои, видимые только в режиме «Спутник» (ADR-004 §5, * вариант H-B). Объявлены в style.json / style-dark.json с * visibility: none; здесь только переключаем видимость. */ const SATELLITE_HALO_LAYER_IDS = [ 'trails-track-halo-satellite', 'trails-path-bridleway-halo-satellite', ]; /** * Пары (base-layer, halo-underlay) для синхронизации halo с * пользовательскими чекбоксами «Грунтовки» / «Тропы» * (ADR-004 §9, TRZ §5.7). Источник истины: halo видим ⇔ * (текущая база === 'satellite') AND (соответствующий чекбокс ON). */ const TRAIL_HALO_PAIRS = [ { base: 'trails-track', halo: 'trails-track-halo-satellite' }, { base: 'trails-path-bridleway', halo: 'trails-path-bridleway-halo-satellite' }, ]; /** * Сохранённое значение `layerState.basemap` на время активного * режима «Спутник» (ADR-004 §8, TRZ §5.6). `null` означает «сейчас * на схеме, восстанавливать нечего». При входе в «Спутник» сохраняем * сюда `layerState.basemap`, при выходе — восстанавливаем и * обнуляем. Это сохраняет выбор пользователя по «Базовая карта» * через ход «Схема → Спутник → Схема» без рассинхрона с * `layerState.basemap`. */ let _savedBasemapState = null; /** * Возвращает выбранную пользователем подложку из localStorage. * * Любое значение, кроме известных (`'schematic'` / `'satellite'`), * трактуется как дефолт `'schematic'` (TRZ §4.3, U-04). Безопасно к * приватному режиму браузера: при ошибке доступа к localStorage * возвращает дефолт. * @returns {('schematic'|'satellite')} */ function getStoredBaseLayer() { try { const v = window.localStorage.getItem('map-base-layer'); return v === 'satellite' ? 'satellite' : 'schematic'; } catch (_) { return 'schematic'; } } /** * Обработчик сегментированного переключателя «Подложка» (атрибут * onclick кнопок «Схема» / «Спутник»). * * Идемпотентен: повторный вызов с уже активным значением — no-op * (U-05): не пишет в localStorage и не трогает стиль карты. * @param {('schematic'|'satellite')} base - выбранная подложка. */ function onBaseLayerToggle(base) { if (base !== 'schematic' && base !== 'satellite') return; const current = getStoredBaseLayer(); if (current === base) return; try { window.localStorage.setItem('map-base-layer', base); } catch (_) { /* private mode — фича остаётся per-session */ } applyBaseLayer(base); syncBaseLayerUI(base); } /** * Применяет выбранную подложку к карте (TRZ §5.2, ADR-004 §3, §5). * * Для `'satellite'`: лениво создаёт source/layer (если их ещё нет), * вставляет слой ниже первого terrain/trails/POI-слоя, скрывает * `osm-base`, включает halo-underlay-слои у trails, выставляет * тёмный halo у POI и тёмный background, чтобы белый фон не * «бликовал» под медленно подгружающимися плитками. * * Для `'schematic'`: возвращает все динамически изменённые свойства * к значениям, объявленным в текущем `style.json` / `style-dark.json`. * @param {('schematic'|'satellite')} base */ function applyBaseLayer(base) { const map = window._map; if (!map) return; if (base === 'satellite') { if (!map.getSource(SATELLITE_SOURCE_ID)) { map.addSource(SATELLITE_SOURCE_ID, { type: 'raster', tiles: [SATELLITE_TILE_URL], tileSize: 256, minzoom: 0, maxzoom: 19, attribution: SATELLITE_ATTRIBUTION, }); } if (!map.getLayer(SATELLITE_LAYER_ID)) { const before = _firstOverlayLayerId(map); map.addLayer({ id: SATELLITE_LAYER_ID, type: 'raster', source: SATELLITE_SOURCE_ID, paint: { 'raster-opacity': 1.0, 'raster-resampling': 'linear' }, layout: { visibility: 'none' }, }, before); } if (map.getLayer(SATELLITE_LAYER_ID)) { map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'visible'); } // ET-007 P1-5 / ADR-004 §8: запоминаем layerState.basemap и // принудительно скрываем osm-base. layerState.basemap не меняем — // это пользовательский выбор «Базовая карта», его восстановим при // возврате на «Схему». if (_savedBasemapState === null && typeof layerState !== 'undefined') { _savedBasemapState = layerState.basemap; } if (map.getLayer('osm-base')) { map.setLayoutProperty('osm-base', 'visibility', 'none'); } // CSS-hook: скрыть кнопку #btn-basemap пока активен спутник // (гибридный режим out of scope — BRD §3). Defensive: mock-DOM в // unit-тестах может не иметь classList.add/remove. _setBodyClass('satellite-active', true); // ET-007 P1-6: halo синхронизирован с состоянием чекбоксов // «Грунтовки» / «Тропы», а не безусловно включён. _applyTrailHaloVisibility(map, 'satellite'); // ET-008: halo публичных треков на спутнике if (typeof applyGpsHaloVisibility === 'function') { applyGpsHaloVisibility(map); } _applyPoiSatellitePaint(map, true); _applyBackgroundForSatellite(map, true); } else { if (map.getLayer(SATELLITE_LAYER_ID)) { map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'none'); } // ET-007 P1-5: восстановить выбор пользователя по «Базовой карте» // (если он раньше выключал osm-base — оставить выключенным). if (map.getLayer('osm-base')) { const wantOsm = _savedBasemapState !== false; // default visible map.setLayoutProperty('osm-base', 'visibility', wantOsm ? 'visible' : 'none'); } _savedBasemapState = null; _setBodyClass('satellite-active', false); // На «Схеме» halo всегда скрыт независимо от чекбоксов. _applyTrailHaloVisibility(map, 'schematic'); // ET-008: halo публичных треков выключить if (typeof applyGpsHaloVisibility === 'function') { applyGpsHaloVisibility(map); } _applyPoiSatellitePaint(map, false); _applyBackgroundForSatellite(map, false); } } /** * Восстанавливает выбор подложки из localStorage и применяет его к * карте (TRZ §5.3). * * Вызывается: * - в `rebuildMapOverlays()` (первым — TRZ §5.5) после смены темы; * - в IIFE-инициализаторе ниже на старте приложения. * * Идемпотентна: дублирующий вызов с тем же сохранённым значением — no-op. */ function restoreBaseLayerState() { const base = getStoredBaseLayer(); syncBaseLayerUI(base); applyBaseLayer(base); } /** * Синхронизирует визуальное состояние кнопок переключателя подложки * с переданным значением (TRZ §5.4). * @param {('schematic'|'satellite')} base */ function syncBaseLayerUI(base) { const schBtn = document.getElementById('base-btn-schematic'); const satBtn = document.getElementById('base-btn-satellite'); if (schBtn) schBtn.classList.toggle('active', base === 'schematic'); if (satBtn) satBtn.classList.toggle('active', base === 'satellite'); } // ── Приватные хелперы (ADR-004 §5) ───────────────────────────────── /** * Defensive переключатель класса на document.body. Реальный браузерный * `classList` имеет `add`/`remove`/`toggle`, но в unit-тестах * (tests/unit/base_layer.test.js) мок-DOM собран минимально и содержит * только `contains`. Использует `toggle(name, on)` если доступен, * иначе деградирует в no-op (тестовая среда — побочные эффекты на body * не важны). */ function _setBodyClass(name, on) { if (typeof document === 'undefined' || !document.body) return; const cl = document.body.classList; if (!cl) return; if (typeof cl.toggle === 'function') { cl.toggle(name, !!on); return; } if (on && typeof cl.add === 'function') { cl.add(name); return; } if (!on && typeof cl.remove === 'function') { cl.remove(name); return; } } /** * Возвращает id первого «верхнего» слоя (terrain/trails/POI), * чтобы спутник был добавлен ПОД ним и terrain/trails/POI/маршрут * остались видны поверх спутника без вычисления beforeId для каждого * слоя в отдельности (ADR-004 §O-A). */ function _firstOverlayLayerId(map) { const style = map.getStyle && map.getStyle(); if (!style || !style.layers) return undefined; const first = style.layers.find((l) => l.id.startsWith('terrain-') || l.id.startsWith('trails-') || l.id.startsWith('poi-') ); return first ? first.id : undefined; } /** * Применяет видимость halo-underlay-слоёв у trails по правилу * «halo видим ⇔ (base === 'satellite') AND (соответствующий чекбокс ON)» * (TRZ §5.7, ADR-004 §9, 12-review.md P1-6). * * Состояние чекбоксов читается из DOM (`#trails-track-cb`, * `#trails-path-cb`). Если узлов нет (тесты под jsdom без HTML или * ранний вызов до отрисовки попапа) — пары считаются ON (`true`), * это совпадает с дефолтом `restoreTrailsState()`. * * @param {object} map - инстанс MapLibre. * @param {('schematic'|'satellite')} base - текущая база. */ function _applyTrailHaloVisibility(map, base) { const trackCb = (typeof document !== 'undefined') && document.getElementById && document.getElementById('trails-track-cb'); const pathCb = (typeof document !== 'undefined') && document.getElementById && document.getElementById('trails-path-cb'); const trackOn = trackCb ? !!trackCb.checked : true; const pathOn = pathCb ? !!pathCb.checked : true; const onByBase = base === 'satellite'; const pairs = [ { halo: 'trails-track-halo-satellite', checked: trackOn }, { halo: 'trails-path-bridleway-halo-satellite', checked: pathOn }, ]; pairs.forEach((p) => { if (!map.getLayer(p.halo)) return; const visibility = (onByBase && p.checked) ? 'visible' : 'none'; map.setLayoutProperty(p.halo, 'visibility', visibility); }); } // Обратная совместимость для существующих unit-тестов, которые могли // ссылаться на _toggleSatelliteHalo до P1-6 рефакторинга. Делегирует // на новую функцию с правильным base. См. tests/unit/base_layer.test.js. function _toggleSatelliteHalo(map, enabled) { _applyTrailHaloVisibility(map, enabled ? 'satellite' : 'schematic'); } /** * Применяет правки paint к POI labels/circles в зависимости от * активной подложки (ADR-004 §5, Data §5.1–5.2). * * На «Спутнике» — белый текст с чёрным halo у подписей и белая * обводка у кружков, чтобы POI оставались читаемыми поверх тёмных * снимков. На «Схеме» — возврат к baseline-значениям текущей темы * из `style.json` / `style-dark.json` (см. Data §5.1). * * Менять обе пары (`text-color` + `text-halo-*`) обязательно: иначе * baseline-текст светлой темы `#333333` поверх чёрного halo не * читается (см. 12-review.md P1-2). */ function _applyPoiSatellitePaint(map, satellite) { const dark = (typeof document !== 'undefined') && document.body && document.body.classList && document.body.classList.contains('theme-dark'); if (map.getLayer('poi-labels')) { if (satellite) { // Satellite — единые значения для обеих тем (Data §5.2). map.setPaintProperty('poi-labels', 'text-color', '#ffffff'); map.setPaintProperty('poi-labels', 'text-halo-color', '#000000'); map.setPaintProperty('poi-labels', 'text-halo-width', 2); } else { // Schematic — baseline текущей темы (Data §5.1). map.setPaintProperty('poi-labels', 'text-color', dark ? '#e0e0e0' : '#333333'); map.setPaintProperty('poi-labels', 'text-halo-color', dark ? '#1a1a2e' : '#ffffff'); map.setPaintProperty('poi-labels', 'text-halo-width', dark ? 2 : 1.5); } } if (map.getLayer('poi-circles')) { if (satellite) { map.setPaintProperty('poi-circles', 'circle-stroke-color', '#ffffff'); map.setPaintProperty('poi-circles', 'circle-stroke-width', 2); } else { map.setPaintProperty('poi-circles', 'circle-stroke-color', dark ? '#333333' : '#ffffff'); map.setPaintProperty('poi-circles', 'circle-stroke-width', 1.5); } } } /** * Меняет цвет background-слоя под спутником на единый тёмно-серый * `#2a2a2a` (обе темы) — TRZ §1 REQ-F-03, ADR-004 §6. На «Схеме» — * возврат к baseline текущей темы из Data §5 (`#f0ede6` light / * `#1a1a2e` dark; именно `#1a1a2e`, как в `style-dark.json:28`, а * не `#1a1a1a` из более раннего черновика — см. 12-review.md P1-4). */ function _applyBackgroundForSatellite(map, satellite) { if (!map.getLayer('background')) return; if (satellite) { // Единая константа для обеих тем (ADR-004 §6). map.setPaintProperty('background', 'background-color', '#2a2a2a'); } else { const dark = (typeof document !== 'undefined') && document.body && document.body.classList && document.body.classList.contains('theme-dark'); map.setPaintProperty('background', 'background-color', dark ? '#1a1a2e' : '#f0ede6'); } } // <<< ET-007 base layer toggle block <<< // >>> ET-005 unit toggle block >>> // Переключатель единиц измерения расстояний (км/мили) в попапе рельефа. // Выбор единицы, его персистентность и форматирование вынесены в // src/web/units.js (ADR-0001). Здесь — UI-обработчик попапа и единый // оркестратор пересчёта видимых расстояний. // См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md /** * Обработчик сегментированного переключателя единиц в попапе рельефа * (атрибут onclick кнопок «км» / «мили»). * * Делегирует смену единицы модулю Units. Пересчёт всех видимых * расстояний выполняет оркестратор onUnitChange() по событию * 'unitchange'; здесь дополнительно синхронизируется вид кнопок. * @param {('km'|'mi')} unit - выбранная единица измерения. */ function onUnitToggle(unit) { Units.setUnit(unit); syncUnitToggleUI(); } /** * Синхронизирует визуальное состояние кнопок «км» / «мили» с текущей * выбранной единицей измерения. * * Вызывается при инициализации страницы (восстановление выбора из * localStorage — AC-3) и после каждого переключения. */ function syncUnitToggleUI() { const unit = Units.getUnit(); const kmBtn = document.getElementById('unit-btn-km'); const miBtn = document.getElementById('unit-btn-mi'); if (kmBtn) kmBtn.classList.toggle('active', unit === 'km'); if (miBtn) miBtn.classList.toggle('active', unit === 'mi'); } /** * Единый оркестратор пересчёта расстояний при смене единицы измерения * (ADR-0001 п.6). Подписан на событие 'unitchange' ровно один раз — * вместо россыпи подписок по компонентам пере-вызывает функции * отрисовки всех видимых поверхностей с расстояниями. * * Внутреннее состояние остаётся метрическим: конвертация выполняется * исключительно в Units.formatDistance(). */ function onUnitChange() { // Карточки основного маршрута, лист точек и мини-карточка. if (routeResults.length > 0) { renderRouteCards(routeResults); updateMiniRouteCard(); } if (routeWaypoints.length > 0) { renderWaypointsList(); } // Линейка: updateRulerLabels() обновляет и подписи отрезков, и итог. if (rulerMarkers.length > 0) { updateRulerLabels(); } // Карточки связки и «красивого» маршрута — только при активном режиме, // чтобы перерисовка не возвращала на карту скрытые слои. if (linkMode && linkRoutes.length > 0) { renderLinkCards(linkRoutes); } if (scenicMode && scenicRoutes.length > 0) { drawScenicRoutes(scenicRoutes, activeScenicIdx); } // Масштабная линейка карты (риск R3). if (typeof window._updateScaleZoom === 'function') { window._updateScaleZoom(); } } // <<< ET-005 unit toggle block <<< function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) { const map = window._map; if (!map) return; const sourceId = id + '-source'; if (enabled) { // Add source if not exists if (!map.getSource(sourceId)) { map.addSource(sourceId, { type: 'raster', tiles: [tileUrl], tileSize: 256, scheme: 'tms', minzoom: minzoom, maxzoom: maxzoom }); } // Add layer if not exists if (!map.getLayer(id)) { // Insert before first road/trail layer for correct z-order const firstTrailLayer = map.getStyle().layers.find(l => l.id.startsWith('trails-') || l.id.startsWith('poi-') ); map.addLayer({ id: id, type: 'raster', source: sourceId, paint: { 'raster-opacity': opacity, 'raster-resampling': 'linear' }, minzoom: minzoom, maxzoom: maxzoom }, firstTrailLayer ? firstTrailLayer.id : undefined); } } else { // Remove layer and source if (map.getLayer(id)) map.removeLayer(id); if (map.getSource(sourceId)) map.removeSource(sourceId); } } function updateHillshadeAvailability() { const map = window._map; if (!map) return; const zoom = map.getZoom(); const cb = document.getElementById('terrain-hillshade-cb'); const hint = document.getElementById('terrain-hillshade-hint'); const label = cb ? cb.closest('.terrain-checkbox') : null; if (zoom < 10) { if (cb) cb.disabled = true; if (label) label.classList.add('disabled'); if (hint) hint.style.display = 'inline'; } else { if (cb) cb.disabled = false; if (label) label.classList.remove('disabled'); if (hint) hint.style.display = 'none'; } } function restoreTerrainState() { const hillshade = localStorage.getItem('terrain-hillshade') === '1'; const tri = localStorage.getItem('terrain-tri') === '1'; const hillshadeCb = document.getElementById('terrain-hillshade-cb'); const triCb = document.getElementById('terrain-tri-cb'); if (hillshadeCb) hillshadeCb.checked = hillshade; if (triCb) triCb.checked = tri; if (hillshade || tri) { onTerrainCheckbox(); } // Update button active state const btn = document.getElementById('terrain-toggle'); if (btn) btn.classList.toggle('active', hillshade || tri); } // Hook into map load and zoom changes (function initTerrain() { const map = window._map; if (map) { map.on('zoomend', updateHillshadeAvailability); map.on('style.load', () => { // Re-apply terrain after style change (theme switch) setTimeout(restoreTerrainState, 100); }); // Initial state restoreBaseLayerState(); restoreTerrainState(); restoreTrailsState(); restorePoiState(); } else { // Map not ready yet, wait const interval = setInterval(() => { if (window._map) { clearInterval(interval); window._map.on('zoomend', updateHillshadeAvailability); window._map.on('style.load', () => { setTimeout(restoreTerrainState, 100); }); updateHillshadeAvailability(); restoreBaseLayerState(); restoreTerrainState(); restoreTrailsState(); restorePoiState(); } }, 500); } })(); // ─── Standalone Search Mode ────────────────────────────────────── let searchModeActive = false; let standaloneSearchTimeout = null; function toggleSearchMode() { searchModeActive = !searchModeActive; const panel = document.getElementById('search-panel'); const btn = document.getElementById('tb-search'); if (searchModeActive) { panel.style.display = 'block'; btn.classList.add('active'); const input = document.getElementById('standalone-search-input'); input.value = ''; document.getElementById('standalone-search-results').innerHTML = ''; setTimeout(() => input.focus(), 100); input.oninput = () => { clearTimeout(standaloneSearchTimeout); const q = input.value.trim(); if (q.length < 2) { document.getElementById('standalone-search-results').innerHTML = ''; return; } standaloneSearchTimeout = setTimeout(() => doStandaloneSearch(q), 400); }; input.onkeydown = (e) => { if (e.key === 'Escape') toggleSearchMode(); }; } else { panel.style.display = 'none'; btn.classList.remove('active'); } } async function doStandaloneSearch(query) { const resultsEl = document.getElementById('standalone-search-results'); resultsEl.innerHTML = '
Поиск...
'; 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); const data = await resp.json(); if (!data.length) { resultsEl.innerHTML = '
Ничего не найдено
'; return; } resultsEl.innerHTML = data.map(item => { const parts = item.display_name.split(','); const name = parts[0].trim(); const sub = parts.slice(1, 3).join(',').trim(); return `
${name}
${sub ? `
${sub}
` : ''}
`; }).join(''); } catch(e) { resultsEl.innerHTML = '
Ошибка поиска
'; } } function standaloneSelectResult(lat, lon, name) { toggleSearchMode(); window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 800 }); }