Клиентская загрузка GPX 1.1: парсинг через DOMParser с чанковой конвертацией (ADR-003), отрисовка треков и waypoints на карте, панель #sheet-gpx со списком треков, статистикой и canvas-профилем высот. GPX-слои встают ниже маршрута OSRM и восстанавливаются после смены стиля карты (REQ-F-13). - src/web/gpx.js — новый модуль фичи (ADR-002): парсинг, модель window.gpxTracks, слои/маркеры карты, sheet-gpx, профиль высот - index.html / app.css — кнопка загрузки, кнопка тулбара, панель #sheet-gpx, toast-уведомления, индикатор парсинга - app.js — один хук rebuildGpxOverlays() в rebuildMapOverlays() - тесты: gpx.test.js (node --test, U-01..U-21) + test_gpx_upload.py (pytest: статические проверки + JS-раннер) Refs: ET-006 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3133 lines
120 KiB
JavaScript
3133 lines
120 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════
|
||
// 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() {
|
||
// Re-apply terrain and trails after style change
|
||
restoreTerrainState();
|
||
restoreTrailsState();
|
||
restorePoiState();
|
||
|
||
// 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 += `<div class="skeleton-card">
|
||
<div class="skeleton skeleton-line h20 w60"></div>
|
||
<div class="skeleton skeleton-line w80"></div>
|
||
<div class="skeleton skeleton-line w40"></div>
|
||
</div>`;
|
||
}
|
||
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 = '<div class="my-location-dot"></div><div class="my-location-pulse"></div>';
|
||
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 = `<svg width="28" height="36" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<pattern id="chk-${uid}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||
<rect width="4" height="4" fill="black"/>
|
||
<rect x="4" y="0" width="4" height="4" fill="white"/>
|
||
<rect x="0" y="4" width="4" height="4" fill="white"/>
|
||
<rect x="4" y="4" width="4" height="4" fill="black"/>
|
||
</pattern>
|
||
</defs>
|
||
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z" fill="url(#chk-${uid})" stroke="white" stroke-width="1.5"/>
|
||
<text x="14" y="19" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="11" font-weight="700" fill="white" stroke="black" stroke-width="0.5">F</text>
|
||
</svg>`;
|
||
} else {
|
||
el.innerHTML = `<svg width="28" height="36" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z" fill="${bg}" stroke="white" stroke-width="1.5"/>
|
||
<text x="14" y="19" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="${label.length > 1 ? '9' : '11'}" font-weight="700" fill="white">${label}</text>
|
||
</svg>`;
|
||
}
|
||
|
||
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 `<svg width="20" height="26" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<pattern id="checker-${uid}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||
<rect width="4" height="4" fill="black"/>
|
||
<rect x="4" y="0" width="4" height="4" fill="white"/>
|
||
<rect x="0" y="4" width="4" height="4" fill="white"/>
|
||
<rect x="4" y="4" width="4" height="4" fill="black"/>
|
||
</pattern>
|
||
<clipPath id="pin-clip-${uid}">
|
||
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z"/>
|
||
</clipPath>
|
||
</defs>
|
||
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z" fill="url(#checker-${uid})" stroke="white" stroke-width="1.5"/>
|
||
<text x="14" y="19" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="9" font-weight="700" fill="white" stroke="black" stroke-width="0.5">${label}</text>
|
||
</svg>`;
|
||
}
|
||
|
||
return `<svg width="20" height="26" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z" fill="${color}" stroke="white" stroke-width="1.5"/>
|
||
<text x="14" y="19" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="${fs}" font-weight="700" fill="white">${label}</text>
|
||
</svg>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="wl-onboarding">
|
||
<div class="wl-onboard-field" id="wl-onboard-start">
|
||
<div class="wl-pin">${waypointPinSvg('S', '#2EA043')}</div>
|
||
<div style="flex:1">
|
||
<input class="wl-onboard-input" id="wl-onboard-input-start"
|
||
type="text" placeholder="Откуда? Введи название..."
|
||
autocomplete="off" autocorrect="off">
|
||
<div class="wl-search-results" id="wl-onboard-results-start"></div>
|
||
</div>
|
||
</div>
|
||
<div class="wl-onboard-hint">или тапни на карте</div>
|
||
</div>`;
|
||
_initOnboardSearch('start');
|
||
_initWaypointDragHandles(list);
|
||
return;
|
||
}
|
||
|
||
// ── Onboarding: only start added, need finish ──────────────────
|
||
// (handled below after normal list render)
|
||
|
||
const gripSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>`;
|
||
|
||
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 `<div class="wl-item" id="wl-item-${i}" data-idx="${i}">
|
||
<div class="wl-pin">${waypointPinSvg(label, color)}</div>
|
||
<div class="wl-info">
|
||
<span class="wl-label" id="wl-label-${i}">${coordText}</span>
|
||
${distStr ? `<span class="wl-dist">${distStr}</span>` : ''}
|
||
</div>
|
||
<button class="wl-search-btn" onclick="openWaypointSearch(${i})" title="Поиск">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||
</button>
|
||
<div class="wl-drag-handle" data-idx="${i}">${gripSvg}</div>
|
||
<button class="wl-remove" onclick="removeWaypoint(${i})" title="Удалить">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="wl-search-panel" id="wl-search-panel-${i}" style="display:none">
|
||
<input class="wl-search-input" id="wl-search-input-${i}" type="text" placeholder="Поиск места..." autocomplete="off" autocorrect="off">
|
||
<div class="wl-search-results" id="wl-search-results-${i}"></div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Кнопка «Добавить точку» в стиле wl-item
|
||
if (routeWaypoints.length < 10) {
|
||
html += `<div class="wl-item wl-add" onclick="addWaypointMode()">
|
||
<div class="wl-pin">${waypointPinSvg('+', 'var(--text3)')}</div>
|
||
<span class="wl-label">Добавить точку</span>
|
||
</div>`;
|
||
}
|
||
|
||
// Onboarding finish field: only start added, no finish yet
|
||
if (routeWaypoints.length === 1) {
|
||
html += `
|
||
<div class="wl-onboard-field" id="wl-onboard-finish">
|
||
<div class="wl-pin">${waypointPinSvg('F', '#FF3B1F')}</div>
|
||
<div style="flex:1">
|
||
<input class="wl-onboard-input" id="wl-onboard-input-finish"
|
||
type="text" placeholder="Куда? Введи название..."
|
||
autocomplete="off" autocorrect="off">
|
||
<div class="wl-search-results" id="wl-onboard-results-finish"></div>
|
||
</div>
|
||
</div>
|
||
<div class="wl-onboard-hint">или тапни на карте</div>`;
|
||
}
|
||
|
||
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 = '<div class="wl-search-result-item"><span style="color:var(--text3)">Поиск...</span></div>';
|
||
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 = '<div class="wl-search-result-item"><span style="color:var(--text3)">Ничего не найдено</span></div>';
|
||
return;
|
||
}
|
||
resultsEl.innerHTML = data.map(item => {
|
||
const parts = (item.display_name || '').split(', ');
|
||
const name = parts[0];
|
||
const sub = parts.slice(1, 3).join(', ');
|
||
return `<div class="wl-search-result-item" onclick="_selectOnboardResult('${type}', ${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\'")}')">
|
||
<div class="wl-search-result-name">${name}</div>
|
||
${sub ? `<div class="wl-search-result-sub">${sub}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
resultsEl.innerHTML = '<div class="wl-search-result-item"><span style="color:var(--red)">Ошибка</span></div>';
|
||
}
|
||
}
|
||
|
||
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 `<div class="route-card${isActive ? ' active' : ''}" onclick="selectRoute(${i})">
|
||
<div class="rc-header">
|
||
<span class="rc-dot" style="background:${color}"></span>
|
||
<span class="rc-title">Вариант ${i + 1}</span>
|
||
<span class="rc-meta">${Units.formatDistance(route.distance_m)} · ${timeStr}</span>
|
||
</div>
|
||
<div class="rc-bar-wrap">
|
||
<div class="rc-bar">
|
||
<div class="rc-bar-dirt" style="width:${dirtPct}%"></div>
|
||
<div class="rc-bar-asphalt" style="width:${asphPct}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="rc-bar-label">${dirtPct}% грунт${asphPct ? ` · ${asphPct}% асфальт` : ''}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ─── GPX экспорт ───────────────────────────────────────────────────
|
||
function escapeXml(str) {
|
||
return (str || '').replace(/&/g,'&').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 ` <wpt lat="${wp.lat}" lon="${wp.lon}"><name>${escapeXml(name)}</name></wpt>`;
|
||
});
|
||
const markers = loadMarkers();
|
||
markers.forEach(m => {
|
||
wpts.push(` <wpt lat="${m.lat}" lon="${m.lon}"><name>${escapeXml(m.name)}</name><sym>${escapeXml(m.icon)}</sym></wpt>`);
|
||
});
|
||
|
||
const trkpts = route.geometry.coordinates.map(([lon, lat]) =>
|
||
` <trkpt lat="${lat}" lon="${lon}"/>`
|
||
).join('\n');
|
||
|
||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||
<gpx version="1.1" creator="Enduro Trails" xmlns="http://www.topografix.com/GPX/1/1">
|
||
<metadata>
|
||
<name>Enduro route ${dateStr}</name>
|
||
<desc>${distKm} км · ${dirtPct}% грунт</desc>
|
||
<time>${now.toISOString()}</time>
|
||
</metadata>
|
||
${wpts.join('\n')}
|
||
<trk>
|
||
<name>Enduro route ${dateStr}</name>
|
||
<trkseg>
|
||
${trkpts}
|
||
</trkseg>
|
||
</trk>
|
||
</gpx>`;
|
||
}
|
||
|
||
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) =>
|
||
`<button class="marker-type-btn" onclick="selectMarkerType(${i}, ${lngLat.lat}, ${lngLat.lng})">
|
||
<span class="mt-icon">${ic}</span>
|
||
<span class="mt-label">${['Флаг','Лагерь','Ремонт','Заправка','Вода','Точка'][i]}</span>
|
||
</button>`
|
||
).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(`
|
||
<div class="popup-title">${escapeXml(markerData.name)}</div>
|
||
<div class="popup-row"><span class="popup-key">Координаты</span><span class="popup-val">${markerData.lat.toFixed(5)}, ${markerData.lon.toFixed(5)}</span></div>
|
||
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;">
|
||
<button onclick="useMarkerAsA(${markerData.id})" style="flex:1;padding:3px 6px;background:var(--success);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">→ Точка A</button>
|
||
<button onclick="useMarkerAsB(${markerData.id})" style="flex:1;padding:3px 6px;background:var(--red);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">→ Точка B</button>
|
||
<button onclick="removeMarker(${markerData.id})" style="flex:1;padding:3px 6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;">🗑 Удалить</button>
|
||
</div>
|
||
`);
|
||
|
||
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 = '<div class="szb-scale"><span class="szb-label">30 km</span></div><div class="szb-zoom">z7</div>';
|
||
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 = `
|
||
<div class="popup-title">${props.name || 'Без названия'}</div>
|
||
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${props.highway || '-'}</span></div>
|
||
<div class="popup-row"><span class="popup-key">Покрытие</span><span class="popup-val">${props.surface || '-'}</span></div>
|
||
<div class="popup-row"><span class="popup-key">Категория</span><span class="popup-val">${props.tracktype || '-'}</span></div>
|
||
<div class="popup-row"><span class="popup-key">Длина</span><span class="popup-val">${formatLength(props.length_m)}</span></div>
|
||
${props.mtb_scale ? `<div class="popup-row"><span class="popup-key">MTB scale</span><span class="popup-val">${props.mtb_scale}</span></div>` : ''}
|
||
`;
|
||
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 = `
|
||
<div class="popup-title">${props.name || 'Без названия'}</div>
|
||
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${poiTypeLabel(props.poi_type)}</span></div>
|
||
`;
|
||
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 = '<div class="wl-search-result-item"><span class="wl-search-result-name" style="color:var(--text3)">Поиск...</span></div>';
|
||
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 = '<div class="wl-search-result-item"><span class="wl-search-result-name" style="color:var(--text3)">Ничего не найдено</span></div>';
|
||
return;
|
||
}
|
||
resultsEl.innerHTML = data.map(item => {
|
||
const parts = (item.display_name || '').split(', ');
|
||
const name = parts[0];
|
||
const sub = parts.slice(1, 3).join(', ');
|
||
return `<div class="wl-search-result-item" onclick="selectWaypointSearchResult(${idx}, ${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\'")}')">
|
||
<div class="wl-search-result-name">${name}</div>
|
||
${sub ? `<div class="wl-search-result-sub">${sub}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
resultsEl.innerHTML = '<div class="wl-search-result-item"><span class="wl-search-result-name" style="color:var(--red)">Ошибка поиска</span></div>';
|
||
}
|
||
}
|
||
|
||
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 = '<div class="search-result-item"><span class="sri-name" style="color:var(--text3)">Поиск...</span></div>';
|
||
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 = '<div class="search-result-item"><span class="sri-name" style="color:var(--text3)">Ничего не найдено</span></div>';
|
||
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 `<div class="search-result-item" onclick="selectSearchResult(${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\'")}')">
|
||
<div class="sri-name">${name}</div>
|
||
<div class="sri-sub">${detail}</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
results.innerHTML = '<div class="search-result-item"><span class="sri-name" style="color:var(--red)">Ошибка поиска</span></div>';
|
||
}
|
||
}
|
||
|
||
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 =>
|
||
`<div class="poi-row">
|
||
<span class="poi-row-label"><span class="poi-icon">${pt.icon}</span> ${pt.label}</span>
|
||
<span class="poi-row-count">${p[pt.key] || 0}</span>
|
||
</div>`
|
||
).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 = `
|
||
<div class="rc-header">
|
||
<span class="rc-dot" style="background:${col}"></span>
|
||
<span class="rc-title">Вариант ${i+1}</span>
|
||
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
|
||
<span class="rc-time">${time}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт</div>
|
||
`;
|
||
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 `<div class="scenic-poi-item">${label}${name}</div>`;
|
||
}).join('');
|
||
return `<div class="route-card ${i===activeIdx?'active':''}" onclick="selectScenicRoute(${i})">
|
||
<div class="rc-header">
|
||
<span class="rc-dot" style="background:${col}"></span>
|
||
<span class="rc-title">${r.name || 'Вариант '+(i+1)}</span>
|
||
<span class="rc-km">${Units.formatDistance(r.distance_m, { precision: 0 })}</span>
|
||
<span class="rc-time">${time}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text2)">${dirt}% грунт · score=${r.scenic_score||0}</div>
|
||
${pois ? '<div style="margin-top:4px">'+pois+'</div>' : ''}
|
||
</div>`;
|
||
}).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 = '<div class="wl-search-result-item"><span style="color:var(--text3)">Поиск...</span></div>';
|
||
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 = '<div class="wl-search-result-item"><span style="color:var(--text3)">Ничего не найдено</span></div>';
|
||
return;
|
||
}
|
||
resultsEl.innerHTML = data.map(item => {
|
||
const parts = (item.display_name || '').split(', ');
|
||
const name = parts[0];
|
||
const sub = parts.slice(1, 3).join(', ');
|
||
return `<div class="wl-search-result-item" onclick="_selectMiniOnboardResult('${type}', ${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\'")}')">
|
||
<div class="wl-search-result-name">${name}</div>
|
||
${sub ? `<div class="wl-search-result-sub">${sub}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
resultsEl.innerHTML = '<div class="wl-search-result-item"><span style="color:var(--red)">Ошибка</span></div>';
|
||
}
|
||
}
|
||
|
||
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 = `<div class="route-loading">
|
||
<div class="route-spinner"></div>
|
||
<span style="color:var(--text3);font-size:13px">Строю маршрут...</span>
|
||
</div>`;
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
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-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-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
|
||
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();
|
||
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 = '<div class="search-result-item"><span style="color:var(--text3)">Поиск...</span></div>';
|
||
|
||
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 = '<div class="search-result-item"><span style="color:var(--text3)">Ничего не найдено</span></div>';
|
||
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 `<div class="search-result-item" onclick="standaloneSelectResult(${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\\'")}')">
|
||
<div class="search-result-name">${name}</div>
|
||
${sub ? `<div class="search-result-sub">${sub}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
} catch(e) {
|
||
resultsEl.innerHTML = '<div class="search-result-item"><span style="color:var(--red)">Ошибка поиска</span></div>';
|
||
}
|
||
}
|
||
|
||
function standaloneSelectResult(lat, lon, name) {
|
||
toggleSearchMode();
|
||
window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 800 });
|
||
}
|