// ═══════════════════════════════════════════════════════════════════
// 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 = ``;
} else {
el.innerHTML = ``;
}
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 ``;
}
return ``;
}
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 `
${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 ``;
}).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 = `
${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 `
${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 });
}