auto-sync: 2026-05-04 11:10:01

This commit is contained in:
Stream
2026-05-04 11:10:01 +03:00
parent 6477326eda
commit ff7d301fc4

View File

@@ -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 `<div class="waypoint-row" draggable="true"
ondragstart="onWpDragStart(event,${i})"
ondragover="onWpDragOver(event,${i})"
ondrop="onWpDrop(event,${i})"
ondragleave="onWpDragLeave(event)">
<span class="waypoint-label ${labelClass}">${labelText}</span>
<span class="waypoint-coords">${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}</span>
<button class="waypoint-remove" onclick="removeWaypoint(${i})" title="Удалить точку">✕</button>
</div>`;
}).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 = `
<div class="route-coverage-bar">
<div style="width:${s.track_lev12_pct}%;background:#FFD700" title="Lev1-2: ${(s.track_lev12_m/1000).toFixed(1)} км"></div>
<div style="width:${s.track_lev345_pct}%;background:#FF4400" title="Lev3-5: ${(s.track_lev345_m/1000).toFixed(1)} км"></div>
<div style="width:${s.path_pct}%;background:#cc0000" title="Тропы: ${(s.path_m/1000).toFixed(1)} км"></div>
<div style="width:${s.asphalt_pct}%;background:#aaaaaa" title="Асфальт: ${(s.asphalt_m/1000).toFixed(1)} км"></div>
</div>`;
summaryHtml = `<div class="route-card-summary">${s.dirt_total_pct}% грунт · ${s.asphalt_pct}% асфальт</div>`;
detailsHtml = `
<div class="route-card-details" id="route-details-${i}" style="display:none;">
<div class="route-stat-row">🟡 Lev1-2 &nbsp; ${(s.track_lev12_m/1000).toFixed(1)} км &nbsp; ${s.track_lev12_pct}%</div>
<div class="route-stat-row">🔴 Lev3-5 &nbsp; ${(s.track_lev345_m/1000).toFixed(1)} км &nbsp; ${s.track_lev345_pct}%</div>
<div class="route-stat-row">🔴 Тропы &nbsp; ${(s.path_m/1000).toFixed(1)} км &nbsp; ${s.path_pct}%</div>
<div class="route-stat-row">⬜ Асфальт &nbsp; ${(s.asphalt_m/1000).toFixed(1)} км &nbsp; ${s.asphalt_pct}%</div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button onclick="event.stopPropagation();downloadGPX()"
style="flex:1;padding:4px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:11px;">
📥 GPX
</button>
<button onclick="event.stopPropagation();selectRoute(${i})"
style="flex:1;padding:4px;background:#ff6600;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">
Выбрать
</button>
</div>
</div>
<button class="route-details-toggle" onclick="event.stopPropagation();toggleRouteDetails(${i})">Подробнее ▼</button>`;
} else {
detailsHtml = `
<div class="route-card-details" id="route-details-${i}" style="display:none;">
<div style="margin-top:6px;display:flex;gap:6px;">
<button onclick="event.stopPropagation();downloadGPX()"
style="flex:1;padding:4px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:11px;">
📥 GPX
</button>
<button onclick="event.stopPropagation();selectRoute(${i})"
style="flex:1;padding:4px;background:#ff6600;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">
Выбрать
</button>
</div>
</div>
<button class="route-details-toggle" onclick="event.stopPropagation();toggleRouteDetails(${i})">Подробнее ▼</button>`;
}
return `<div class="route-card${isActive ? ' active' : ''}" id="route-card-${i}"
onclick="selectRoute(${i})"
onmouseenter="highlightRoute(${i})"
onmouseleave="unhighlightRoute(${i})">
<div class="route-card-header">
<span class="route-color-dot" style="background:${color}"></span>
<span class="route-card-title">Вариант ${i + 1}</span>
<span class="route-card-dist">${distKm} км</span>
<span class="route-card-time">${timeStr}</span>
</div>
${barHtml}
${summaryHtml}
${detailsHtml}
</div>`;
}).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 ` <wpt lat="${wp.lat}" lon="${wp.lon}"><name>${escapeXml(name)}</name></wpt>`;
});
// Добавить флажки из localStorage
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');
const gpx = `<?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>`;
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(`
<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:#00aa44;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:#cc0000;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:#f0f0f0;border:1px solid #ccc;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);
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 = `
<div class="popup-title">${props.name || 'Без названия'}</div>
@@ -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 = `
<div class="popup-title">${props.name || 'Без названия'}</div>
@@ -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 = `<span>${label}</span><span style="cursor:pointer;opacity:0.7;" onclick="event.stopPropagation();removeRulerPoint(${idx})">✕</span>`;