diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js
index eec0c57..9396f27 100644
--- a/tasks/enduro-trails/prototype/static/app.js
+++ b/tasks/enduro-trails/prototype/static/app.js
@@ -1,3 +1,25 @@
+// ─── Утилиты ──────────────────────────────────────────────────────────────────
+
+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 (mins === 0) return `${days} дн ${hours} ч`;
+ return `${days} дн ${hours} ч ${mins} мин`;
+ }
+ if (mins === 0) return `${hours} ч`;
+ return `${hours} ч ${mins} мин`;
+}
+
+function formatDist(m) {
+ if (!m) return '—';
+ if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
+ return Math.round(m) + ' м';
+}
+
// ─── Компас ───────────────────────────────────────────────────────────────────
let compassLocked = false;
@@ -84,6 +106,648 @@ function toggleLayer(group) {
});
}
+
+// ─── Роутинг — состояние ──────────────────────────────────────────────────────
+const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888'];
+
+let routeMode = false;
+let routeWaypoints = []; // [{lon, lat}, ...]
+let routeResults = []; // массив маршрутов из API
+let activeRouteIdx = 0;
+let waypointMarkers = []; // MapLibre маркеры точек
+let addingWaypoint = false; // режим добавления промежуточной точки
+let buildDebounceTimer = null;
+
+function getBasePath() {
+ return window.location.pathname.replace(/\/[^/]*$/, '') || '';
+}
+
+// ─── Режим маршрута ───────────────────────────────────────────────────────────
+function toggleRouteMode() {
+ routeMode = !routeMode;
+ const btn = document.getElementById('btn-route');
+ const panel = document.getElementById('route-panel');
+ if (routeMode) {
+ btn.classList.add('active');
+ panel.style.display = 'block';
+ clearRoute();
+ window._map.getCanvas().style.cursor = 'crosshair';
+ } else {
+ btn.classList.remove('active');
+ panel.style.display = 'none';
+ clearRoute();
+ window._map.getCanvas().style.cursor = '';
+ }
+}
+
+function clearRoute() {
+ // Убираем маркеры точек
+ waypointMarkers.forEach(m => m.remove());
+ waypointMarkers = [];
+ routeWaypoints = [];
+ routeResults = [];
+ activeRouteIdx = 0;
+ addingWaypoint = false;
+
+ // Убираем слои маршрутов
+ const map = window._map;
+ if (map) {
+ for (let i = 0; i < 5; i++) {
+ if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i);
+ if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline');
+ }
+ if (map.getSource('route')) map.removeSource('route');
+ for (let i = 0; i < 5; i++) {
+ if (map.getSource('route-' + i)) map.removeSource('route-' + i);
+ }
+ }
+
+ document.getElementById('route-status').textContent = 'Кликни точку старта';
+ document.getElementById('route-actions').style.display = 'none';
+ document.getElementById('route-cards').innerHTML = '';
+ document.getElementById('waypoints-list').innerHTML = '';
+ document.getElementById('btn-add-waypoint').style.display = '';
+
+ if (routeMode && map) map.getCanvas().style.cursor = 'crosshair';
+}
+
+// ─── Добавление промежуточной точки ──────────────────────────────────────────
+function startAddWaypoint() {
+ if (routeWaypoints.length >= 10) return;
+ addingWaypoint = true;
+ window._map.getCanvas().style.cursor = 'crosshair';
+ document.getElementById('route-status').textContent = 'Кликни на карте для добавления точки';
+}
+
+// ─── Маркеры точек ────────────────────────────────────────────────────────────
+function createWaypointMarkerEl(index, total) {
+ const el = document.createElement('div');
+ el.className = 'route-waypoint-marker';
+ let bg, text, color = '#fff';
+ if (index === 0) {
+ bg = '#00aa44'; text = 'A';
+ el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`;
+ } else if (index === total - 1) {
+ bg = '#cc0000'; text = 'B';
+ el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`;
+ } else {
+ text = String(index);
+ el.style.cssText = `width:18px;height:18px;background:#fff;border:2px solid #0066ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#0066ff;box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`;
+ }
+ el.textContent = text;
+ return el;
+}
+
+function rebuildWaypointMarkers() {
+ waypointMarkers.forEach(m => m.remove());
+ waypointMarkers = [];
+ const map = window._map;
+ routeWaypoints.forEach((wp, i) => {
+ const el = createWaypointMarkerEl(i, routeWaypoints.length);
+ const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true })
+ .setLngLat([wp.lon, wp.lat])
+ .addTo(map);
+
+ // Захватываем индекс в замыкании
+ (function(idx) {
+ marker.on('dragend', () => {
+ const lngLat = marker.getLngLat();
+ routeWaypoints[idx] = { lon: lngLat.lng, lat: lngLat.lat };
+ renderWaypointsList();
+ debounceBuildRoute();
+ });
+ })(i);
+
+ waypointMarkers.push(marker);
+ });
+}
+
+function renderWaypointsList() {
+ const list = document.getElementById('waypoints-list');
+ if (!routeWaypoints.length) { list.innerHTML = ''; return; }
+
+ list.innerHTML = routeWaypoints.map((wp, i) => {
+ let labelClass, labelText;
+ if (i === 0) { labelClass = 'start'; labelText = 'A'; }
+ else if (i === routeWaypoints.length - 1) { labelClass = 'end'; labelText = 'B'; }
+ else { labelClass = 'mid'; labelText = String(i); }
+
+ return `
+ ${labelText}
+ ${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}
+
+
`;
+ }).join('');
+
+ // Показываем/скрываем кнопку добавления
+ document.getElementById('btn-add-waypoint').style.display =
+ routeWaypoints.length >= 10 ? 'none' : '';
+}
+
+// ─── Drag-and-drop порядка точек ──────────────────────────────────────────────
+let dragWpIdx = null;
+
+function onWpDragStart(e, idx) {
+ dragWpIdx = idx;
+ e.dataTransfer.effectAllowed = 'move';
+}
+
+function onWpDragOver(e, idx) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ document.querySelectorAll('.waypoint-row').forEach((r, i) => {
+ r.classList.toggle('drag-over', i === idx);
+ });
+}
+
+function onWpDragLeave(e) {
+ e.currentTarget.classList.remove('drag-over');
+}
+
+function onWpDrop(e, idx) {
+ e.preventDefault();
+ document.querySelectorAll('.waypoint-row').forEach(r => r.classList.remove('drag-over'));
+ if (dragWpIdx === null || dragWpIdx === idx) return;
+ const moved = routeWaypoints.splice(dragWpIdx, 1)[0];
+ routeWaypoints.splice(idx, 0, moved);
+ dragWpIdx = null;
+ rebuildWaypointMarkers();
+ renderWaypointsList();
+ if (routeWaypoints.length >= 2) debounceBuildRoute();
+}
+
+function removeWaypoint(idx) {
+ routeWaypoints.splice(idx, 1);
+ rebuildWaypointMarkers();
+ renderWaypointsList();
+ if (routeWaypoints.length >= 2) {
+ debounceBuildRoute();
+ } else {
+ // Убираем маршруты с карты
+ const map = window._map;
+ for (let i = 0; i < 5; i++) {
+ if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i);
+ if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline');
+ if (map.getSource('route-' + i)) map.removeSource('route-' + i);
+ }
+ routeResults = [];
+ document.getElementById('route-cards').innerHTML = '';
+ document.getElementById('route-actions').style.display =
+ routeWaypoints.length >= 2 ? 'block' : 'none';
+ document.getElementById('route-status').textContent =
+ routeWaypoints.length === 0 ? 'Кликни точку старта' :
+ routeWaypoints.length === 1 ? 'Кликни точку финиша' : '';
+ }
+}
+
+
+// ─── Построение маршрута ──────────────────────────────────────────────────────
+function debounceBuildRoute() {
+ clearTimeout(buildDebounceTimer);
+ buildDebounceTimer = setTimeout(buildRoute, 300);
+}
+
+async function buildRoute() {
+ if (routeWaypoints.length < 2) return;
+ const map = window._map;
+ const basePath = getBasePath();
+
+ document.getElementById('route-status').textContent = '⏳ Строю маршрут...';
+ const btn = document.getElementById('btn-build-route');
+ if (btn) btn.textContent = '⏳ Строю...';
+
+ try {
+ const resp = await fetch(basePath + '/api/route', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ waypoints: routeWaypoints, alternatives: 5 }),
+ });
+ if (!resp.ok) throw new Error('Маршрут не найден');
+ const data = await resp.json();
+ routeResults = data.routes || [];
+ if (!routeResults.length) throw new Error('Маршрут не найден');
+
+ // Убираем старые слои
+ for (let i = 0; i < 5; i++) {
+ if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i);
+ if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline');
+ if (map.getSource('route-' + i)) map.removeSource('route-' + i);
+ }
+
+ // Рисуем все маршруты
+ routeResults.forEach((route, i) => {
+ const color = ROUTE_COLORS[i] || '#888888';
+ const isActive = i === activeRouteIdx;
+ map.addSource('route-' + i, {
+ type: 'geojson',
+ data: { type: 'Feature', geometry: route.geometry, properties: {} }
+ });
+ // Обводка (белая) для активного
+ map.addLayer({
+ id: 'route-line-' + i + '-outline',
+ type: 'line',
+ source: 'route-' + i,
+ paint: {
+ 'line-color': '#ffffff',
+ 'line-width': isActive ? 7 : 4,
+ 'line-opacity': isActive ? 0.6 : 0,
+ },
+ layout: { 'line-cap': 'round', 'line-join': 'round' }
+ });
+ map.addLayer({
+ id: 'route-line-' + i,
+ type: 'line',
+ source: 'route-' + i,
+ paint: {
+ 'line-color': color,
+ 'line-width': isActive ? 5 : 3,
+ 'line-opacity': isActive ? 0.95 : 0.5,
+ },
+ layout: { 'line-cap': 'round', 'line-join': 'round' }
+ });
+
+ // Клик на линию маршрута
+ map.on('click', 'route-line-' + i, (e) => {
+ e.stopPropagation ? e.stopPropagation() : null;
+ selectRoute(i);
+ });
+ map.on('mouseenter', 'route-line-' + i, () => {
+ map.getCanvas().style.cursor = 'pointer';
+ highlightRoute(i);
+ });
+ map.on('mouseleave', 'route-line-' + i, () => {
+ map.getCanvas().style.cursor = routeMode ? 'crosshair' : '';
+ unhighlightRoute(i);
+ });
+ });
+
+ activeRouteIdx = 0;
+ renderRouteCards(routeResults);
+ document.getElementById('route-status').textContent = `✅ ${routeResults.length} маршрут(ов)`;
+ document.getElementById('route-actions').style.display = 'block';
+
+ } catch(e) {
+ document.getElementById('route-status').textContent = '❌ ' + e.message;
+ }
+
+ if (btn) btn.textContent = '🗺️ Построить маршрут';
+}
+
+// ─── Выбор и подсветка маршрутов ─────────────────────────────────────────────
+function selectRoute(idx) {
+ activeRouteIdx = idx;
+ const map = window._map;
+ routeResults.forEach((_, i) => {
+ const isActive = i === idx;
+ if (map.getLayer('route-line-' + i)) {
+ map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3);
+ map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5);
+ }
+ if (map.getLayer('route-line-' + i + '-outline')) {
+ map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4);
+ map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0);
+ }
+ });
+ // Обновляем CSS карточек
+ document.querySelectorAll('.route-card').forEach((card, i) => {
+ card.classList.toggle('active', i === idx);
+ });
+}
+
+function highlightRoute(idx) {
+ const map = window._map;
+ if (map.getLayer('route-line-' + idx)) {
+ map.setPaintProperty('route-line-' + idx, 'line-width', 7);
+ map.setPaintProperty('route-line-' + idx, 'line-opacity', 1);
+ }
+}
+
+function unhighlightRoute(idx) {
+ const isActive = idx === activeRouteIdx;
+ const map = window._map;
+ if (map.getLayer('route-line-' + idx)) {
+ map.setPaintProperty('route-line-' + idx, 'line-width', isActive ? 5 : 3);
+ map.setPaintProperty('route-line-' + idx, 'line-opacity', isActive ? 0.95 : 0.5);
+ }
+}
+
+// ─── Карточки маршрутов ───────────────────────────────────────────────────────
+function renderRouteCards(routes) {
+ const container = document.getElementById('route-cards');
+ container.innerHTML = routes.map((route, i) => {
+ const color = ROUTE_COLORS[i] || '#888888';
+ const distKm = (route.distance_m / 1000).toFixed(1);
+ const timeStr = formatDuration(route.duration_s);
+ const isActive = i === activeRouteIdx;
+
+ let barHtml = '';
+ let summaryHtml = '';
+ let detailsHtml = '';
+
+ if (route.stats) {
+ const s = route.stats;
+ barHtml = `
+ `;
+ summaryHtml = `${s.dirt_total_pct}% грунт · ${s.asphalt_pct}% асфальт
`;
+ detailsHtml = `
+
+
🟡 Lev1-2 ${(s.track_lev12_m/1000).toFixed(1)} км ${s.track_lev12_pct}%
+
🔴 Lev3-5 ${(s.track_lev345_m/1000).toFixed(1)} км ${s.track_lev345_pct}%
+
🔴 Тропы ${(s.path_m/1000).toFixed(1)} км ${s.path_pct}%
+
⬜ Асфальт ${(s.asphalt_m/1000).toFixed(1)} км ${s.asphalt_pct}%
+
+
+
+
+
+ `;
+ } else {
+ detailsHtml = `
+
+
+
+
+
+
+ `;
+ }
+
+ return `
+
+ ${barHtml}
+ ${summaryHtml}
+ ${detailsHtml}
+
`;
+ }).join('');
+}
+
+function toggleRouteDetails(idx) {
+ const details = document.getElementById('route-details-' + idx);
+ const btn = details ? details.nextElementSibling : null;
+ if (!details) return;
+ const isOpen = details.style.display !== 'none';
+ details.style.display = isOpen ? 'none' : 'block';
+ if (btn) btn.textContent = isOpen ? 'Подробнее ▼' : 'Свернуть ▲';
+}
+
+
+// ─── GPX экспорт ─────────────────────────────────────────────────────────────
+function escapeXml(str) {
+ return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+function downloadGPX() {
+ const route = routeResults[activeRouteIdx];
+ if (!route) return;
+
+ const now = new Date();
+ const dateStr = now.toISOString().slice(0, 10);
+ const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15);
+ const filename = `enduro-${timeStr}.gpx`;
+
+ const distKm = (route.distance_m / 1000).toFixed(1);
+ const dirtPct = route.stats ? route.stats.dirt_total_pct : '?';
+
+ // Waypoints: точки маршрута
+ const wpts = routeWaypoints.map((wp, i) => {
+ const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`;
+ return ` ${escapeXml(name)}`;
+ });
+
+ // Добавить флажки из localStorage
+ const markers = loadMarkers();
+ markers.forEach(m => {
+ wpts.push(` ${escapeXml(m.name)}${escapeXml(m.icon)}`);
+ });
+
+ // Трек
+ const trkpts = route.geometry.coordinates.map(([lon, lat]) =>
+ ` `
+ ).join('\n');
+
+ const gpx = `
+
+
+ Enduro route ${dateStr}
+ ${distKm} км · ${dirtPct}% грунт
+
+
+${wpts.join('\n')}
+
+ Enduro route ${dateStr}
+
+${trkpts}
+
+
+`;
+
+ const blob = new Blob([gpx], { type: 'application/gpx+xml' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+// ─── Флажки / именованные метки ──────────────────────────────────────────────
+const MARKER_ICONS = ['🚩', '⛺', '🔧', '⛽', '💧', '📍'];
+const MARKERS_KEY = 'enduro_markers';
+let markerMode = false;
+let namedMarkerObjects = {}; // id -> MapLibre Marker
+
+function loadMarkers() {
+ try {
+ return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]');
+ } catch(e) {
+ return [];
+ }
+}
+
+function saveMarkers(markers) {
+ try {
+ localStorage.setItem(MARKERS_KEY, JSON.stringify(markers));
+ } catch(e) {
+ console.warn('localStorage недоступен');
+ }
+}
+
+function toggleMarkerMode() {
+ markerMode = !markerMode;
+ const btn = document.getElementById('btn-markers');
+ if (markerMode) {
+ btn.classList.add('active');
+ window._map.getCanvas().style.cursor = 'crosshair';
+ } else {
+ btn.classList.remove('active');
+ window._map.getCanvas().style.cursor = '';
+ }
+}
+
+function addMarker(lngLat) {
+ const markers = loadMarkers();
+ if (markers.length >= 50) {
+ alert('Достигнут лимит 50 меток');
+ return;
+ }
+
+ // Простой диалог выбора иконки и имени
+ const iconChoice = promptIconChoice();
+ if (iconChoice === null) return; // отмена
+
+ const rawName = prompt('Название метки (Enter = автоимя):');
+ if (rawName === null) return; // отмена
+ const autoName = rawName.trim() || `Метка ${markers.length + 1}`;
+
+ const marker = {
+ id: Date.now(),
+ name: autoName,
+ icon: iconChoice,
+ lat: lngLat.lat,
+ lon: lngLat.lng
+ };
+ markers.push(marker);
+ saveMarkers(markers);
+ drawNamedMarker(marker);
+}
+
+function promptIconChoice() {
+ const msg = 'Выберите иконку:\n' + MARKER_ICONS.map((ic, i) => `${i+1}. ${ic}`).join('\n') + '\n\nВведите номер (1-6) или Enter для 🚩:';
+ const input = prompt(msg);
+ if (input === null) return null;
+ const idx = parseInt(input, 10) - 1;
+ if (idx >= 0 && idx < MARKER_ICONS.length) return MARKER_ICONS[idx];
+ return MARKER_ICONS[0];
+}
+
+function drawNamedMarker(markerData) {
+ const map = window._map;
+ const el = document.createElement('div');
+ el.className = 'named-marker-el';
+ el.textContent = markerData.icon;
+ el.title = markerData.name;
+
+ const popup = new maplibregl.Popup({ offset: 25, closeButton: true })
+ .setHTML(`
+
+
+
+
+
+
+
+ `);
+
+ const mlMarker = new maplibregl.Marker({ element: el, anchor: 'bottom' })
+ .setLngLat([markerData.lon, markerData.lat])
+ .setPopup(popup)
+ .addTo(map);
+
+ el.addEventListener('click', () => mlMarker.togglePopup());
+ namedMarkerObjects[markerData.id] = mlMarker;
+}
+
+function renderMarkers() {
+ const markers = loadMarkers();
+ markers.forEach(m => drawNamedMarker(m));
+}
+
+function removeMarker(id) {
+ if (namedMarkerObjects[id]) {
+ namedMarkerObjects[id].remove();
+ delete namedMarkerObjects[id];
+ }
+ const markers = loadMarkers().filter(m => m.id !== id);
+ saveMarkers(markers);
+}
+
+function useMarkerAsA(id) {
+ const markers = loadMarkers();
+ const m = markers.find(x => x.id === id);
+ if (!m) return;
+ if (!routeMode) {
+ toggleRouteMode();
+ }
+ if (routeWaypoints.length === 0) {
+ routeWaypoints.push({ lon: m.lon, lat: m.lat });
+ } else {
+ routeWaypoints[0] = { lon: m.lon, lat: m.lat };
+ }
+ rebuildWaypointMarkers();
+ renderWaypointsList();
+ updateRouteActionsVisibility();
+ if (routeWaypoints.length >= 2) debounceBuildRoute();
+ // Закрыть попап
+ if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove();
+}
+
+function useMarkerAsB(id) {
+ const markers = loadMarkers();
+ const m = markers.find(x => x.id === id);
+ if (!m) return;
+ if (!routeMode) {
+ toggleRouteMode();
+ }
+ if (routeWaypoints.length === 0) {
+ routeWaypoints.push({ lon: m.lon, lat: m.lat });
+ routeWaypoints.push({ lon: m.lon, lat: m.lat });
+ } else if (routeWaypoints.length === 1) {
+ routeWaypoints.push({ lon: m.lon, lat: m.lat });
+ } else {
+ routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat };
+ }
+ rebuildWaypointMarkers();
+ renderWaypointsList();
+ updateRouteActionsVisibility();
+ if (routeWaypoints.length >= 2) debounceBuildRoute();
+ if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove();
+}
+
+function clearAllMarkers() {
+ if (!confirm('Удалить все метки?')) return;
+ Object.values(namedMarkerObjects).forEach(m => m.remove());
+ namedMarkerObjects = {};
+ saveMarkers([]);
+}
+
+function updateRouteActionsVisibility() {
+ document.getElementById('route-actions').style.display =
+ routeWaypoints.length >= 2 ? 'block' : 'none';
+ document.getElementById('route-status').textContent =
+ routeWaypoints.length === 0 ? 'Кликни точку старта' :
+ routeWaypoints.length === 1 ? 'Кликни точку финиша' : '';
+}
+
+
// ─── Map init ─────────────────────────────────────────────────────────────────
async function initMap() {
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
@@ -112,6 +776,7 @@ async function initMap() {
initRouteClicks(map);
initRulerClicks(map);
initSearch();
+ renderMarkers();
});
map.on('error', (e) => {
@@ -159,6 +824,8 @@ async function initMap() {
['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => {
map.on('click', layerId, (e) => {
+ // Не показываем попап трека если активен режим маршрута или линейки
+ if (routeMode || rulerMode || markerMode) return;
const props = e.features[0].properties;
const html = `
@@ -170,11 +837,16 @@ async function initMap() {
`;
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
- map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
- map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
+ 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 = `
@@ -182,10 +854,15 @@ async function initMap() {
`;
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
- map.on('mouseenter', 'poi-circles', () => { map.getCanvas().style.cursor = 'pointer'; });
- map.on('mouseleave', 'poi-circles', () => { map.getCanvas().style.cursor = ''; });
+ 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'],
});
@@ -206,91 +883,51 @@ async function checkDataAvailability() {
}
}
-// ─── Роутинг ──────────────────────────────────────────────────────────────────
-let routeMode = false;
-let routeStart = null;
-let routeEnd = null;
-let routeMarkers = [];
-
-function toggleRouteMode() {
- routeMode = !routeMode;
- const btn = document.getElementById('btn-route');
- const panel = document.getElementById('route-panel');
- if (routeMode) {
- btn.classList.add('active');
- panel.style.display = 'block';
- clearRoute();
- window._map.getCanvas().style.cursor = 'crosshair';
- } else {
- btn.classList.remove('active');
- panel.style.display = 'none';
- clearRoute();
- window._map.getCanvas().style.cursor = '';
- }
-}
-
-function clearRoute() {
- routeStart = null;
- routeEnd = null;
- routeMarkers.forEach(m => m.remove());
- routeMarkers = [];
- const map = window._map;
- if (map.getLayer('route-line')) map.removeLayer('route-line');
- if (map.getSource('route')) map.removeSource('route');
- document.getElementById('route-status').textContent = 'Кликни точку старта';
- document.getElementById('route-info').style.display = 'none';
- if (routeMode) map.getCanvas().style.cursor = 'crosshair';
-}
-
-async function buildRoute() {
- const map = window._map;
- document.getElementById('route-status').textContent = '⏳ Строю маршрут...';
- const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
- try {
- const resp = await fetch(
- `${basePath}/api/route?from_lon=${routeStart[0]}&from_lat=${routeStart[1]}&to_lon=${routeEnd[0]}&to_lat=${routeEnd[1]}`
- );
- if (!resp.ok) throw new Error('Маршрут не найден');
- const data = await resp.json();
- if (map.getSource('route')) {
- map.getSource('route').setData(data);
- } else {
- map.addSource('route', { type: 'geojson', data });
- map.addLayer({
- id: 'route-line',
- type: 'line',
- source: 'route',
- paint: { 'line-color': '#0066ff', 'line-width': 4, 'line-opacity': 0.85 },
- layout: { 'line-cap': 'round', 'line-join': 'round' }
- });
- }
- const p = data.properties;
- document.getElementById('route-distance').textContent = `${p.distance_km} км`;
- document.getElementById('route-duration').textContent = `~${p.duration_min} мин`;
- document.getElementById('route-status').textContent = '✅ Готово';
- document.getElementById('route-info').style.display = 'block';
- } catch(e) {
- document.getElementById('route-status').textContent = '❌ ' + e.message;
- }
-}
-
+// ─── Клики на карте (маршрут + метки) ────────────────────────────────────────
function initRouteClicks(map) {
map.on('click', (e) => {
+ // Режим добавления метки
+ if (markerMode) {
+ addMarker(e.lngLat);
+ toggleMarkerMode();
+ return;
+ }
+
if (!routeMode) return;
+
const { lng, lat } = e.lngLat;
- if (!routeStart) {
- routeStart = [lng, lat];
- const el = document.createElement('div');
- el.style.cssText = 'width:14px;height:14px;background:#00aa00;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)';
- routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map));
+
+ // Режим добавления промежуточной точки
+ if (addingWaypoint) {
+ addingWaypoint = false;
+ map.getCanvas().style.cursor = 'crosshair';
+ // Вставляем перед последней точкой (B)
+ if (routeWaypoints.length >= 2) {
+ routeWaypoints.splice(routeWaypoints.length - 1, 0, { lon: lng, lat: lat });
+ } else {
+ routeWaypoints.push({ lon: lng, lat: lat });
+ }
+ rebuildWaypointMarkers();
+ renderWaypointsList();
+ updateRouteActionsVisibility();
+ if (routeWaypoints.length >= 2) debounceBuildRoute();
+ return;
+ }
+
+ // Обычный режим: A → B
+ if (routeWaypoints.length === 0) {
+ routeWaypoints.push({ lon: lng, lat: lat });
+ rebuildWaypointMarkers();
+ renderWaypointsList();
document.getElementById('route-status').textContent = 'Кликни точку финиша';
- } else if (!routeEnd) {
- routeEnd = [lng, lat];
- const el = document.createElement('div');
- el.style.cssText = 'width:14px;height:14px;background:#cc0000;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)';
- routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map));
+ } else if (routeWaypoints.length === 1) {
+ routeWaypoints.push({ lon: lng, lat: lat });
+ rebuildWaypointMarkers();
+ renderWaypointsList();
+ updateRouteActionsVisibility();
buildRoute();
}
+ // Если уже 2+ точек — клик ничего не делает (используй "+ Точка")
});
}
@@ -410,14 +1047,12 @@ function addRulerPoint(lngLat) {
const label = rulerPoints.length === 1 ? '0 м' :
rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
- // Кружок — anchor: center, строго 10×10
const dot = document.createElement('div');
dot.style.cssText = 'width:10px;height:10px;background:#0088ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3);';
const dotMarker = new maplibregl.Marker({ element: dot, anchor: 'center' })
.setLngLat([lngLat.lng, lngLat.lat])
.addTo(map);
- // Плашка — отдельный маркер, anchor: center, offset вверх на 20px
const labelEl = document.createElement('div');
labelEl.style.cssText = 'display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.75);color:#fff;font-size:11px;font-weight:600;padding:2px 6px;border-radius:3px;white-space:nowrap;';
labelEl.innerHTML = `${label}✕`;