Files
enduro-trails/src/web/app.js
claude-bot 0060003f28
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped
feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
  (bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
  (GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
   GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
  enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc

Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
  debounced fetch + AbortController, фильтры активности/источника,
  цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
  restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
  applyGpsHaloVisibility() в applyBaseLayer()

Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)

Docker:
- gps-collector service с profiles: [batch]

Тесты: 48 новых тестов (unit + integration), 125/125 pass

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:28:54 +00:00

3499 lines
137 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════════
// Enduro Trails - Phase 5 Redesign
// Theme system (auto/light/dark + SunCalc), skeleton, swipe, animations
// ═══════════════════════════════════════════════════════════════════
// ─── Theme System ──────────────────────────────────────────────────
let themeMode = localStorage.getItem('enduro-theme-mode') || 'auto'; // 'auto' | 'light' | 'dark'
let userLat = null;
let userLon = null;
let themeAutoInterval = null;
function isDarkTheme() {
return document.body.classList.contains('theme-dark');
}
function applyTheme() {
if (themeMode === 'light') {
document.body.className = 'theme-light';
} else if (themeMode === 'dark') {
document.body.className = 'theme-dark';
} else {
// auto: use SunCalc
applyAutoTheme();
}
updateThemeButtonIcon();
switchMapStyle();
}
function applyAutoTheme() {
if (themeMode !== 'auto') return;
const now = new Date();
const lat = userLat || 55.75;
const lon = userLon || 37.62;
let isDay = true;
try {
if (typeof SunCalc !== 'undefined') {
const times = SunCalc.getTimes(now, lat, lon);
isDay = now >= times.sunrise && now < times.sunset;
} else {
// Fallback: assume day if 6am-8pm
const h = now.getHours();
isDay = h >= 6 && h < 20;
}
} catch(e) {
const h = now.getHours();
isDay = h >= 6 && h < 20;
}
document.body.className = isDay ? 'theme-light' : 'theme-dark';
updateThemeButtonIcon();
}
function toggleTheme() {
// Cycle: auto → light → dark → auto
if (themeMode === 'auto') themeMode = 'light';
else if (themeMode === 'light') themeMode = 'dark';
else themeMode = 'auto';
localStorage.setItem('enduro-theme-mode', themeMode);
applyTheme();
}
function updateThemeButtonIcon() {
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
const label = document.getElementById('theme-label');
if (!sunIcon || !moonIcon) return;
const dark = isDarkTheme();
if (themeMode === 'auto') {
// Dynamic icon based on actual theme
sunIcon.style.display = dark ? 'none' : 'block';
moonIcon.style.display = dark ? 'block' : 'none';
if (label) label.textContent = 'Авто';
} else if (themeMode === 'light') {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
if (label) label.textContent = 'День';
} else {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
if (label) label.textContent = 'Ночь';
}
}
function switchMapStyle() {
const map = window._map;
if (!map) return;
const dark = isDarkTheme();
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
const tileBase = window.location.origin + basePath;
const styleUrl = dark ? basePath + '/style-dark.json' : basePath + '/style.json';
// Save current position before style change
const center = map.getCenter();
const zoom = map.getZoom();
const bearing = map.getBearing();
const pitch = map.getPitch();
fetch(styleUrl).then(r => {
if (r.ok) return r.json();
throw new Error('Style not available');
}).then(style => {
// Fix tile URLs to absolute (same as initMap)
if (style.sources && style.sources['trails-tiles'] && style.sources['trails-tiles'].tiles) {
style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`];
}
map.setStyle(style);
// Restore position and overlays after style loads
map.once('idle', () => {
map.jumpTo({ center, zoom, bearing, pitch });
rebuildMapOverlays();
});
}).catch(() => {
// Network error or style not available, don't switch
});
}
// Re-add layers after style change
function onMapStyleLoad() {
const map = window._map;
if (!map) return;
// Re-add any active route layers, markers, etc.
rebuildMapOverlays();
}
function rebuildMapOverlays() {
// ET-007: восстановить выбранную подложку первой — чтобы terrain/trails/POI
// оказались поверх неё (см. ADR-004, TRZ §5.5).
if (typeof restoreBaseLayerState === 'function') {
restoreBaseLayerState();
}
// Re-apply terrain and trails after style change
restoreTerrainState();
restoreTrailsState();
restorePoiState();
// ET-008: публичные GPS-треки
if (typeof restorePublicTracksState === 'function') {
restorePublicTracksState();
}
// Re-apply recon circle if active
if (reconMode && reconCenter) {
doRecon(reconCenter[0], reconCenter[1]);
}
// Re-draw route if active
if (routeMode && routeResults.length > 0) {
const savedResults = [...routeResults];
const savedIdx = activeRouteIdx;
routeResults = [];
drawRouteResults(savedResults, savedIdx);
}
// Re-draw scenic routes
if (scenicMode && scenicRoutes.length > 0) {
const savedRoutes = [...scenicRoutes];
scenicRoutes = [];
drawScenicRoutes(savedRoutes, activeScenicIdx);
}
// Re-draw link routes
if (linkMode && linkPoints.length >= 2) {
buildLinkRoute();
}
// Re-draw ruler
if (rulerMode && rulerPoints.length > 0) {
const pts = [...rulerPoints];
rulerPoints = [];
rulerTotal = 0;
rulerMarkers.forEach(m => m.remove());
rulerMarkers = [];
const map = window._map;
if (map.getSource('ruler')) map.removeSource('ruler');
if (map.getLayer('ruler-line')) map.removeLayer('ruler-line');
pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] }));
}
// Re-render named markers
renderMarkers();
// ET-006: восстановить GPX-слои после смены стиля карты (ADR-002, REQ-F-13)
if (typeof rebuildGpxOverlays === 'function') rebuildGpxOverlays();
}
// ─── Utilities ──────────────────────────────────────────────────────
function formatDuration(seconds) {
const totalMin = Math.round(seconds / 60);
if (totalMin < 60) return totalMin + ' мин';
const days = Math.floor(totalMin / 1440);
const hours = Math.floor((totalMin % 1440) / 60);
const mins = totalMin % 60;
if (days > 0) {
if (hours === 0 && mins === 0) return `${days} дн`;
if (mins === 0) return `${days} дн ${hours} ч`;
return `${days} дн ${hours} ч ${mins} мин`;
}
if (mins === 0) return `${hours} ч`;
return `${hours} ч ${mins} мин`;
}
// ET-005: форматирование расстояний централизовано в units.js (ADR-0001).
function formatDist(m) {
if (!m) return '-';
return Units.formatDistance(m);
}
// ─── Sheet Management ──────────────────────────────────────────────
function openSheet(id) {
const sheet = document.getElementById(id);
if (!sheet) return;
// Close all other sheets first
document.querySelectorAll('.bottom-sheet.open').forEach(s => {
if (s.id !== id) closeSheet(s.id);
});
sheet.classList.add('open');
const backdrop = document.getElementById('sheet-backdrop');
backdrop.classList.add('visible');
}
function closeSheet(id) {
const sheet = document.getElementById(id);
if (!sheet) return;
sheet.classList.remove('open');
sheet.style.transform = '';
// Check if any sheets still open
const anyOpen = document.querySelector('.bottom-sheet.open');
if (!anyOpen) {
document.getElementById('sheet-backdrop').classList.remove('visible');
}
}
// Close sheet panel but keep the mode active (route stays on map)
function minimizeSheet(id) {
closeSheet(id);
if (id === 'sheet-route' && routeResults.length > 0) {
showMiniRouteSheet();
}
}
function closeAllSheets() {
document.querySelectorAll('.bottom-sheet.open').forEach(s => {
s.classList.remove('open');
s.style.transform = '';
});
document.getElementById('sheet-backdrop').classList.remove('visible');
}
// ─── Swipe-down to close sheets ────────────────────────────────────
function initSheetSwipe() {
document.querySelectorAll('.bottom-sheet').forEach(sheet => {
let startY = 0;
let isDragging = false;
sheet.addEventListener('touchstart', (e) => {
const rect = sheet.getBoundingClientRect();
const touchY = e.touches[0].clientY;
// Only initiate swipe from the handle area (top 50px of sheet)
if (touchY < rect.top + 50 || e.target.closest('.sheet-handle')) {
isDragging = true;
startY = touchY;
sheet.classList.add('swiping');
}
}, { passive: true });
sheet.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const dy = e.touches[0].clientY - startY;
if (dy > 0) {
sheet.style.transform = `translateY(${dy}px)`;
}
}, { passive: true });
sheet.addEventListener('touchend', (e) => {
if (!isDragging) return;
isDragging = false;
sheet.classList.remove('swiping');
const dy = e.changedTouches[0].clientY - startY;
if (dy > 80) {
const sheetId = sheet.id;
if (sheetId === 'sheet-route' && routeResults && routeResults.length > 0) {
minimizeSheet(sheetId);
} else {
closeSheet(sheetId);
// Deactivate corresponding mode
if (sheetId === 'sheet-route' && routeMode) toggleRouteMode();
else if (sheetId === 'sheet-recon' && reconMode) toggleReconMode();
else if (sheetId === 'sheet-scenic' && scenicMode) toggleScenicMode();
else if (sheetId === 'sheet-link' && linkMode) toggleLinkMode();
}
sheet.style.transform = '';
} else {
sheet.style.transform = '';
}
}, { passive: true });
});
}
// ─── Skeleton Loading ──────────────────────────────────────────────
function showSkeleton(containerId, count) {
const container = document.getElementById(containerId);
if (!container) return;
count = count || 2;
let html = '';
for (let i = 0; i < count; i++) {
html += `<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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');
}
// ET-007 P1-6: синхронизируем halo-underlay-слои с состоянием
// чекбоксов, чтобы на спутнике не оставалось «фантома» halo при
// выключенной грунтовке/тропе. Безопасно к ранней инициализации:
// _applyTrailHaloVisibility определена ниже в том же файле (ET-007
// base layer block). См. ADR-004 §9, TRZ §5.7.
if (typeof _applyTrailHaloVisibility === 'function' &&
typeof getStoredBaseLayer === 'function') {
_applyTrailHaloVisibility(map, getStoredBaseLayer());
}
}
function restoreTrailsState() {
const trackState = localStorage.getItem('trails-track');
const pathState = localStorage.getItem('trails-path');
// Default: both checked (visible)
const trackOn = trackState === null || trackState === '1';
const pathOn = pathState === null || pathState === '1';
const trackCb = document.getElementById('trails-track-cb');
const pathCb = document.getElementById('trails-path-cb');
if (trackCb) trackCb.checked = trackOn;
if (pathCb) pathCb.checked = pathOn;
const map = window._map;
if (map) {
if (map.getLayer('trails-track')) {
map.setLayoutProperty('trails-track', 'visibility', trackOn ? 'visible' : 'none');
}
if (map.getLayer('trails-path-bridleway')) {
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathOn ? 'visible' : 'none');
}
// ET-007 P1-6: тот же контракт, что в onTrailsCheckbox (см. выше).
if (typeof _applyTrailHaloVisibility === 'function' &&
typeof getStoredBaseLayer === 'function') {
_applyTrailHaloVisibility(map, getStoredBaseLayer());
}
}
}
// >>> ET-002 POI visibility block (do not remove markers — used by unit tests) >>>
// Видимость POI (слои poi-circles, poi-labels) управляется чекбоксом
// «POI» в попапе рельефа. Состояние хранится в localStorage под ключом
// 'poi-visible' ('1'/'0'). Источник истины в рантайме — layerState.poi.
// См. docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md
/**
* Применяет видимость группы слоёв POI и синхронизирует layerState.poi.
*
* Единый приватный хелпер: переиспользуется чекбоксом попапа
* (onPoiCheckbox) и восстановлением состояния при загрузке/смене стиля
* (restorePoiState). Не пишет в localStorage — персистентность остаётся
* ответственностью обработчика чекбокса.
*
* @param {boolean} visible - true — показать POI, false — скрыть.
*/
function applyPoiVisibility(visible) {
layerState.poi = visible;
const map = window._map;
if (!map) return;
const visibility = visible ? 'visible' : 'none';
layerGroups.poi.forEach(id => {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', visibility);
}
});
}
/**
* Обработчик чекбокса «POI» в попапе рельефа (атрибут onchange).
*
* Сохраняет выбор в localStorage ('poi-visible': '1' видимы | '0' скрыты)
* и применяет видимость слоёв POI через applyPoiVisibility().
*/
function onPoiCheckbox() {
const checked = document.getElementById('poi-visible-cb').checked;
localStorage.setItem('poi-visible', checked ? '1' : '0');
applyPoiVisibility(checked);
}
/**
* Восстанавливает видимость POI при загрузке страницы и после смены
* стиля карты (переключение темы).
*
* По умолчанию (ключ 'poi-visible' отсутствует или равен '1') POI
* видимы; '0' — скрыты. Синхронизирует чекбокс, layerState.poi и
* фактическую видимость слоёв.
*/
function restorePoiState() {
const stored = localStorage.getItem('poi-visible');
const poiOn = stored === null || stored === '1';
const cb = document.getElementById('poi-visible-cb');
if (cb) cb.checked = poiOn;
applyPoiVisibility(poiOn);
}
// <<< ET-002 POI visibility block <<<
// >>> ET-007 base layer toggle block (do not remove markers — used by unit tests) >>>
// Переключатель базовой подложки карты «Схема» / «Спутник» в попапе слоёв.
// Реализация: ленивое создание спутникового raster-source/layer при первом
// включении «Спутника»; восстановление выбора из localStorage и
// rebuildMapOverlays() после смены темы. POI / trails halo переключаются
// через visibility у декларативных underlay-слоёв (`*-halo-satellite`) и
// setPaintProperty у POI labels/circles. См.
// docs/work-items/ET-007/06-adr/ADR-004-satellite-base-layer.md.
/**
* Параметры спутникового источника и слоя (ADR-004 §4.1, TRZ §4.1).
* URL без API-ключа, HTTPS обязателен, атрибуция Esri.
*/
const SATELLITE_SOURCE_ID = 'satellite-raster';
const SATELLITE_LAYER_ID = 'satellite-base';
const SATELLITE_TILE_URL =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const SATELLITE_ATTRIBUTION =
'Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
/**
* Halo-underlay-слои, видимые только в режиме «Спутник» (ADR-004 §5,
* вариант H-B). Объявлены в style.json / style-dark.json с
* visibility: none; здесь только переключаем видимость.
*/
const SATELLITE_HALO_LAYER_IDS = [
'trails-track-halo-satellite',
'trails-path-bridleway-halo-satellite',
];
/**
* Пары (base-layer, halo-underlay) для синхронизации halo с
* пользовательскими чекбоксами «Грунтовки» / «Тропы»
* (ADR-004 §9, TRZ §5.7). Источник истины: halo видим ⇔
* (текущая база === 'satellite') AND (соответствующий чекбокс ON).
*/
const TRAIL_HALO_PAIRS = [
{ base: 'trails-track', halo: 'trails-track-halo-satellite' },
{ base: 'trails-path-bridleway', halo: 'trails-path-bridleway-halo-satellite' },
];
/**
* Сохранённое значение `layerState.basemap` на время активного
* режима «Спутник» (ADR-004 §8, TRZ §5.6). `null` означает «сейчас
* на схеме, восстанавливать нечего». При входе в «Спутник» сохраняем
* сюда `layerState.basemap`, при выходе — восстанавливаем и
* обнуляем. Это сохраняет выбор пользователя по «Базовая карта»
* через ход «Схема → Спутник → Схема» без рассинхрона с
* `layerState.basemap`.
*/
let _savedBasemapState = null;
/**
* Возвращает выбранную пользователем подложку из localStorage.
*
* Любое значение, кроме известных (`'schematic'` / `'satellite'`),
* трактуется как дефолт `'schematic'` (TRZ §4.3, U-04). Безопасно к
* приватному режиму браузера: при ошибке доступа к localStorage
* возвращает дефолт.
* @returns {('schematic'|'satellite')}
*/
function getStoredBaseLayer() {
try {
const v = window.localStorage.getItem('map-base-layer');
return v === 'satellite' ? 'satellite' : 'schematic';
} catch (_) {
return 'schematic';
}
}
/**
* Обработчик сегментированного переключателя «Подложка» (атрибут
* onclick кнопок «Схема» / «Спутник»).
*
* Идемпотентен: повторный вызов с уже активным значением — no-op
* (U-05): не пишет в localStorage и не трогает стиль карты.
* @param {('schematic'|'satellite')} base - выбранная подложка.
*/
function onBaseLayerToggle(base) {
if (base !== 'schematic' && base !== 'satellite') return;
const current = getStoredBaseLayer();
if (current === base) return;
try {
window.localStorage.setItem('map-base-layer', base);
} catch (_) { /* private mode — фича остаётся per-session */ }
applyBaseLayer(base);
syncBaseLayerUI(base);
}
/**
* Применяет выбранную подложку к карте (TRZ §5.2, ADR-004 §3, §5).
*
* Для `'satellite'`: лениво создаёт source/layer (если их ещё нет),
* вставляет слой ниже первого terrain/trails/POI-слоя, скрывает
* `osm-base`, включает halo-underlay-слои у trails, выставляет
* тёмный halo у POI и тёмный background, чтобы белый фон не
* «бликовал» под медленно подгружающимися плитками.
*
* Для `'schematic'`: возвращает все динамически изменённые свойства
* к значениям, объявленным в текущем `style.json` / `style-dark.json`.
* @param {('schematic'|'satellite')} base
*/
function applyBaseLayer(base) {
const map = window._map;
if (!map) return;
if (base === 'satellite') {
if (!map.getSource(SATELLITE_SOURCE_ID)) {
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tileSize: 256,
minzoom: 0,
maxzoom: 19,
attribution: SATELLITE_ATTRIBUTION,
});
}
if (!map.getLayer(SATELLITE_LAYER_ID)) {
const before = _firstOverlayLayerId(map);
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1.0, 'raster-resampling': 'linear' },
layout: { visibility: 'none' },
}, before);
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'visible');
}
// ET-007 P1-5 / ADR-004 §8: запоминаем layerState.basemap и
// принудительно скрываем osm-base. layerState.basemap не меняем —
// это пользовательский выбор «Базовая карта», его восстановим при
// возврате на «Схему».
if (_savedBasemapState === null && typeof layerState !== 'undefined') {
_savedBasemapState = layerState.basemap;
}
if (map.getLayer('osm-base')) {
map.setLayoutProperty('osm-base', 'visibility', 'none');
}
// CSS-hook: скрыть кнопку #btn-basemap пока активен спутник
// (гибридный режим out of scope — BRD §3). Defensive: mock-DOM в
// unit-тестах может не иметь classList.add/remove.
_setBodyClass('satellite-active', true);
// ET-007 P1-6: halo синхронизирован с состоянием чекбоксов
// «Грунтовки» / «Тропы», а не безусловно включён.
_applyTrailHaloVisibility(map, 'satellite');
// ET-008: halo публичных треков на спутнике
if (typeof applyGpsHaloVisibility === 'function') {
applyGpsHaloVisibility(map);
}
_applyPoiSatellitePaint(map, true);
_applyBackgroundForSatellite(map, true);
} else {
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'none');
}
// ET-007 P1-5: восстановить выбор пользователя по «Базовой карте»
// (если он раньше выключал osm-base — оставить выключенным).
if (map.getLayer('osm-base')) {
const wantOsm = _savedBasemapState !== false; // default visible
map.setLayoutProperty('osm-base', 'visibility', wantOsm ? 'visible' : 'none');
}
_savedBasemapState = null;
_setBodyClass('satellite-active', false);
// На «Схеме» halo всегда скрыт независимо от чекбоксов.
_applyTrailHaloVisibility(map, 'schematic');
// ET-008: halo публичных треков выключить
if (typeof applyGpsHaloVisibility === 'function') {
applyGpsHaloVisibility(map);
}
_applyPoiSatellitePaint(map, false);
_applyBackgroundForSatellite(map, false);
}
}
/**
* Восстанавливает выбор подложки из localStorage и применяет его к
* карте (TRZ §5.3).
*
* Вызывается:
* - в `rebuildMapOverlays()` (первым — TRZ §5.5) после смены темы;
* - в IIFE-инициализаторе ниже на старте приложения.
*
* Идемпотентна: дублирующий вызов с тем же сохранённым значением — no-op.
*/
function restoreBaseLayerState() {
const base = getStoredBaseLayer();
syncBaseLayerUI(base);
applyBaseLayer(base);
}
/**
* Синхронизирует визуальное состояние кнопок переключателя подложки
* с переданным значением (TRZ §5.4).
* @param {('schematic'|'satellite')} base
*/
function syncBaseLayerUI(base) {
const schBtn = document.getElementById('base-btn-schematic');
const satBtn = document.getElementById('base-btn-satellite');
if (schBtn) schBtn.classList.toggle('active', base === 'schematic');
if (satBtn) satBtn.classList.toggle('active', base === 'satellite');
}
// ── Приватные хелперы (ADR-004 §5) ─────────────────────────────────
/**
* Defensive переключатель класса на document.body. Реальный браузерный
* `classList` имеет `add`/`remove`/`toggle`, но в unit-тестах
* (tests/unit/base_layer.test.js) мок-DOM собран минимально и содержит
* только `contains`. Использует `toggle(name, on)` если доступен,
* иначе деградирует в no-op (тестовая среда — побочные эффекты на body
* не важны).
*/
function _setBodyClass(name, on) {
if (typeof document === 'undefined' || !document.body) return;
const cl = document.body.classList;
if (!cl) return;
if (typeof cl.toggle === 'function') { cl.toggle(name, !!on); return; }
if (on && typeof cl.add === 'function') { cl.add(name); return; }
if (!on && typeof cl.remove === 'function') { cl.remove(name); return; }
}
/**
* Возвращает id первого «верхнего» слоя (terrain/trails/POI),
* чтобы спутник был добавлен ПОД ним и terrain/trails/POI/маршрут
* остались видны поверх спутника без вычисления beforeId для каждого
* слоя в отдельности (ADR-004 §O-A).
*/
function _firstOverlayLayerId(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
const first = style.layers.find((l) =>
l.id.startsWith('terrain-') ||
l.id.startsWith('trails-') ||
l.id.startsWith('poi-')
);
return first ? first.id : undefined;
}
/**
* Применяет видимость halo-underlay-слоёв у trails по правилу
* «halo видим ⇔ (base === 'satellite') AND (соответствующий чекбокс ON)»
* (TRZ §5.7, ADR-004 §9, 12-review.md P1-6).
*
* Состояние чекбоксов читается из DOM (`#trails-track-cb`,
* `#trails-path-cb`). Если узлов нет (тесты под jsdom без HTML или
* ранний вызов до отрисовки попапа) — пары считаются ON (`true`),
* это совпадает с дефолтом `restoreTrailsState()`.
*
* @param {object} map - инстанс MapLibre.
* @param {('schematic'|'satellite')} base - текущая база.
*/
function _applyTrailHaloVisibility(map, base) {
const trackCb = (typeof document !== 'undefined') &&
document.getElementById && document.getElementById('trails-track-cb');
const pathCb = (typeof document !== 'undefined') &&
document.getElementById && document.getElementById('trails-path-cb');
const trackOn = trackCb ? !!trackCb.checked : true;
const pathOn = pathCb ? !!pathCb.checked : true;
const onByBase = base === 'satellite';
const pairs = [
{ halo: 'trails-track-halo-satellite', checked: trackOn },
{ halo: 'trails-path-bridleway-halo-satellite', checked: pathOn },
];
pairs.forEach((p) => {
if (!map.getLayer(p.halo)) return;
const visibility = (onByBase && p.checked) ? 'visible' : 'none';
map.setLayoutProperty(p.halo, 'visibility', visibility);
});
}
// Обратная совместимость для существующих unit-тестов, которые могли
// ссылаться на _toggleSatelliteHalo до P1-6 рефакторинга. Делегирует
// на новую функцию с правильным base. См. tests/unit/base_layer.test.js.
function _toggleSatelliteHalo(map, enabled) {
_applyTrailHaloVisibility(map, enabled ? 'satellite' : 'schematic');
}
/**
* Применяет правки paint к POI labels/circles в зависимости от
* активной подложки (ADR-004 §5, Data §5.15.2).
*
* На «Спутнике» — белый текст с чёрным halo у подписей и белая
* обводка у кружков, чтобы POI оставались читаемыми поверх тёмных
* снимков. На «Схеме» — возврат к baseline-значениям текущей темы
* из `style.json` / `style-dark.json` (см. Data §5.1).
*
* Менять обе пары (`text-color` + `text-halo-*`) обязательно: иначе
* baseline-текст светлой темы `#333333` поверх чёрного halo не
* читается (см. 12-review.md P1-2).
*/
function _applyPoiSatellitePaint(map, satellite) {
const dark = (typeof document !== 'undefined') &&
document.body && document.body.classList &&
document.body.classList.contains('theme-dark');
if (map.getLayer('poi-labels')) {
if (satellite) {
// Satellite — единые значения для обеих тем (Data §5.2).
map.setPaintProperty('poi-labels', 'text-color', '#ffffff');
map.setPaintProperty('poi-labels', 'text-halo-color', '#000000');
map.setPaintProperty('poi-labels', 'text-halo-width', 2);
} else {
// Schematic — baseline текущей темы (Data §5.1).
map.setPaintProperty('poi-labels', 'text-color', dark ? '#e0e0e0' : '#333333');
map.setPaintProperty('poi-labels', 'text-halo-color', dark ? '#1a1a2e' : '#ffffff');
map.setPaintProperty('poi-labels', 'text-halo-width', dark ? 2 : 1.5);
}
}
if (map.getLayer('poi-circles')) {
if (satellite) {
map.setPaintProperty('poi-circles', 'circle-stroke-color', '#ffffff');
map.setPaintProperty('poi-circles', 'circle-stroke-width', 2);
} else {
map.setPaintProperty('poi-circles', 'circle-stroke-color', dark ? '#333333' : '#ffffff');
map.setPaintProperty('poi-circles', 'circle-stroke-width', 1.5);
}
}
}
/**
* Меняет цвет background-слоя под спутником на единый тёмно-серый
* `#2a2a2a` (обе темы) — TRZ §1 REQ-F-03, ADR-004 §6. На «Схеме» —
* возврат к baseline текущей темы из Data §5 (`#f0ede6` light /
* `#1a1a2e` dark; именно `#1a1a2e`, как в `style-dark.json:28`, а
* не `#1a1a1a` из более раннего черновика — см. 12-review.md P1-4).
*/
function _applyBackgroundForSatellite(map, satellite) {
if (!map.getLayer('background')) return;
if (satellite) {
// Единая константа для обеих тем (ADR-004 §6).
map.setPaintProperty('background', 'background-color', '#2a2a2a');
} else {
const dark = (typeof document !== 'undefined') &&
document.body && document.body.classList &&
document.body.classList.contains('theme-dark');
map.setPaintProperty('background', 'background-color', dark ? '#1a1a2e' : '#f0ede6');
}
}
// <<< ET-007 base layer toggle block <<<
// >>> ET-005 unit toggle block >>>
// Переключатель единиц измерения расстояний (км/мили) в попапе рельефа.
// Выбор единицы, его персистентность и форматирование вынесены в
// src/web/units.js (ADR-0001). Здесь — UI-обработчик попапа и единый
// оркестратор пересчёта видимых расстояний.
// См. docs/work-items/ET-005/06-adr/adr-0001-unit-toggle-client-side.md
/**
* Обработчик сегментированного переключателя единиц в попапе рельефа
* (атрибут onclick кнопок «км» / «мили»).
*
* Делегирует смену единицы модулю Units. Пересчёт всех видимых
* расстояний выполняет оркестратор onUnitChange() по событию
* 'unitchange'; здесь дополнительно синхронизируется вид кнопок.
* @param {('km'|'mi')} unit - выбранная единица измерения.
*/
function onUnitToggle(unit) {
Units.setUnit(unit);
syncUnitToggleUI();
}
/**
* Синхронизирует визуальное состояние кнопок «км» / «мили» с текущей
* выбранной единицей измерения.
*
* Вызывается при инициализации страницы (восстановление выбора из
* localStorage — AC-3) и после каждого переключения.
*/
function syncUnitToggleUI() {
const unit = Units.getUnit();
const kmBtn = document.getElementById('unit-btn-km');
const miBtn = document.getElementById('unit-btn-mi');
if (kmBtn) kmBtn.classList.toggle('active', unit === 'km');
if (miBtn) miBtn.classList.toggle('active', unit === 'mi');
}
/**
* Единый оркестратор пересчёта расстояний при смене единицы измерения
* (ADR-0001 п.6). Подписан на событие 'unitchange' ровно один раз —
* вместо россыпи подписок по компонентам пере-вызывает функции
* отрисовки всех видимых поверхностей с расстояниями.
*
* Внутреннее состояние остаётся метрическим: конвертация выполняется
* исключительно в Units.formatDistance().
*/
function onUnitChange() {
// Карточки основного маршрута, лист точек и мини-карточка.
if (routeResults.length > 0) {
renderRouteCards(routeResults);
updateMiniRouteCard();
}
if (routeWaypoints.length > 0) {
renderWaypointsList();
}
// Линейка: updateRulerLabels() обновляет и подписи отрезков, и итог.
if (rulerMarkers.length > 0) {
updateRulerLabels();
}
// Карточки связки и «красивого» маршрута — только при активном режиме,
// чтобы перерисовка не возвращала на карту скрытые слои.
if (linkMode && linkRoutes.length > 0) {
renderLinkCards(linkRoutes);
}
if (scenicMode && scenicRoutes.length > 0) {
drawScenicRoutes(scenicRoutes, activeScenicIdx);
}
// Масштабная линейка карты (риск R3).
if (typeof window._updateScaleZoom === 'function') {
window._updateScaleZoom();
}
}
// <<< ET-005 unit toggle block <<<
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
const map = window._map;
if (!map) return;
const sourceId = id + '-source';
if (enabled) {
// Add source if not exists
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: [tileUrl],
tileSize: 256,
scheme: 'tms',
minzoom: minzoom,
maxzoom: maxzoom
});
}
// Add layer if not exists
if (!map.getLayer(id)) {
// Insert before first road/trail layer for correct z-order
const firstTrailLayer = map.getStyle().layers.find(l =>
l.id.startsWith('trails-') || l.id.startsWith('poi-')
);
map.addLayer({
id: id,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': opacity,
'raster-resampling': 'linear'
},
minzoom: minzoom,
maxzoom: maxzoom
}, firstTrailLayer ? firstTrailLayer.id : undefined);
}
} else {
// Remove layer and source
if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource(sourceId)) map.removeSource(sourceId);
}
}
function updateHillshadeAvailability() {
const map = window._map;
if (!map) return;
const zoom = map.getZoom();
const cb = document.getElementById('terrain-hillshade-cb');
const hint = document.getElementById('terrain-hillshade-hint');
const label = cb ? cb.closest('.terrain-checkbox') : null;
if (zoom < 10) {
if (cb) cb.disabled = true;
if (label) label.classList.add('disabled');
if (hint) hint.style.display = 'inline';
} else {
if (cb) cb.disabled = false;
if (label) label.classList.remove('disabled');
if (hint) hint.style.display = 'none';
}
}
function restoreTerrainState() {
const hillshade = localStorage.getItem('terrain-hillshade') === '1';
const tri = localStorage.getItem('terrain-tri') === '1';
const hillshadeCb = document.getElementById('terrain-hillshade-cb');
const triCb = document.getElementById('terrain-tri-cb');
if (hillshadeCb) hillshadeCb.checked = hillshade;
if (triCb) triCb.checked = tri;
if (hillshade || tri) {
onTerrainCheckbox();
}
// Update button active state
const btn = document.getElementById('terrain-toggle');
if (btn) btn.classList.toggle('active', hillshade || tri);
}
// Hook into map load and zoom changes
(function initTerrain() {
const map = window._map;
if (map) {
map.on('zoomend', updateHillshadeAvailability);
map.on('style.load', () => {
// Re-apply terrain after style change (theme switch)
setTimeout(restoreTerrainState, 100);
});
// Initial state
restoreBaseLayerState();
restoreTerrainState();
restoreTrailsState();
restorePoiState();
} else {
// Map not ready yet, wait
const interval = setInterval(() => {
if (window._map) {
clearInterval(interval);
window._map.on('zoomend', updateHillshadeAvailability);
window._map.on('style.load', () => {
setTimeout(restoreTerrainState, 100);
});
updateHillshadeAvailability();
restoreBaseLayerState();
restoreTerrainState();
restoreTrailsState();
restorePoiState();
}
}, 500);
}
})();
// ─── Standalone Search Mode ──────────────────────────────────────
let searchModeActive = false;
let standaloneSearchTimeout = null;
function toggleSearchMode() {
searchModeActive = !searchModeActive;
const panel = document.getElementById('search-panel');
const btn = document.getElementById('tb-search');
if (searchModeActive) {
panel.style.display = 'block';
btn.classList.add('active');
const input = document.getElementById('standalone-search-input');
input.value = '';
document.getElementById('standalone-search-results').innerHTML = '';
setTimeout(() => input.focus(), 100);
input.oninput = () => {
clearTimeout(standaloneSearchTimeout);
const q = input.value.trim();
if (q.length < 2) {
document.getElementById('standalone-search-results').innerHTML = '';
return;
}
standaloneSearchTimeout = setTimeout(() => doStandaloneSearch(q), 400);
};
input.onkeydown = (e) => {
if (e.key === 'Escape') toggleSearchMode();
};
} else {
panel.style.display = 'none';
btn.classList.remove('active');
}
}
async function doStandaloneSearch(query) {
const resultsEl = document.getElementById('standalone-search-results');
resultsEl.innerHTML = '<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 });
}